From 60b2fcf56b923d9de63d0d2992803f739e4f2eef Mon Sep 17 00:00:00 2001 From: Ida Date: Sun, 3 May 2026 15:01:24 +0200 Subject: [PATCH 01/14] =?UTF-8?q?fix:=20alle=20Node=20definitionen=20korri?= =?UTF-8?q?giert=20und=20im=20backend=20gesetzt=20-=20keine=20mapping=20la?= =?UTF-8?q?yer=20sonder=20saubere=20quelldaten,=20fehlende=20dataRef=20par?= =?UTF-8?q?ameter=20hinzugef=C3=BCgt,=20damit=20jede=20node=20kontext=20nu?= =?UTF-8?q?tzen=20kann?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../graphicalEditor/nodeDefinitions/ai.py | 48 ++++++++----- .../nodeDefinitions/clickup.py | 70 +++++++++---------- .../nodeDefinitions/context.py | 2 +- .../graphicalEditor/nodeDefinitions/data.py | 10 +-- .../graphicalEditor/nodeDefinitions/email.py | 28 ++++---- .../graphicalEditor/nodeDefinitions/file.py | 12 ++-- .../graphicalEditor/nodeDefinitions/flow.py | 14 ++-- .../graphicalEditor/nodeDefinitions/input.py | 40 +++++++---- .../nodeDefinitions/redmine.py | 60 ++++++++-------- .../nodeDefinitions/sharepoint.py | 32 ++++----- .../nodeDefinitions/triggers.py | 2 +- .../nodeDefinitions/trustee.py | 26 +++---- .../features/graphicalEditor/nodeRegistry.py | 2 + modules/features/graphicalEditor/portTypes.py | 7 +- .../methods/methodAi/actions/generateCode.py | 9 ++- .../methodAi/actions/generateDocument.py | 9 ++- .../methods/methodAi/actions/webResearch.py | 34 ++++++++- 17 files changed, 239 insertions(+), 166 deletions(-) diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py index 65e97654..857b1516 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t _AI_COMMON_PARAMS = [ - {"name": "requireNeutralization", "type": "boolean", "required": False, + {"name": "requireNeutralization", "type": "bool", "required": False, "frontendType": "checkbox", "default": False, "description": t("Eingaben fuer diesen Call neutralisieren")}, {"name": "allowedModels", "type": "array", "required": False, @@ -19,19 +19,19 @@ AI_NODES = [ "label": t("Prompt"), "description": t("Prompt eingeben und KI führt aus"), "parameters": [ - {"name": "aiPrompt", "type": "string", "required": True, "frontendType": "templateTextarea", + {"name": "aiPrompt", "type": "str", "required": True, "frontendType": "templateTextarea", "description": t("KI-Prompt")}, - {"name": "resultType", "type": "string", "required": False, "frontendType": "select", + {"name": "resultType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]}, "description": t("Ausgabeformat"), "default": "txt"}, {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, - {"name": "context", "type": "string", "required": False, "frontendType": "dataRef", + {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", "description": t("Kontextdaten fuer den Prompt (Upstream-Output binden)"), "default": ""}, - {"name": "documentTheme", "type": "string", "required": False, "frontendType": "select", + {"name": "documentTheme", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]}, "description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"}, - {"name": "simpleMode", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "simpleMode", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Einfacher Modus"), "default": True}, ] + _AI_COMMON_PARAMS, "inputs": 1, @@ -50,12 +50,16 @@ AI_NODES = [ "label": t("Web-Recherche"), "description": t("Recherche im Web"), "parameters": [ - {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", + {"name": "prompt", "type": "str", "required": True, "frontendType": "textarea", "description": t("Recherche-Anfrage")}, + {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", + "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", + "description": t("Optionale Dokumentenliste aus Upstream-Node (Text wird dem Prompt hinzugefügt)"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit"]}}, + "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -69,7 +73,7 @@ AI_NODES = [ "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, - {"name": "summaryLength", "type": "string", "required": False, "frontendType": "select", + {"name": "summaryLength", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["brief", "medium", "detailed"]}, "description": t("Kurz, mittel oder ausführlich"), "default": "medium"}, ] + _AI_COMMON_PARAMS, @@ -89,7 +93,7 @@ AI_NODES = [ "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, - {"name": "targetLanguage", "type": "string", "required": True, "frontendType": "text", + {"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text", "description": t("Zielsprache (z.B. de, en, French)")}, ] + _AI_COMMON_PARAMS, "inputs": 1, @@ -108,7 +112,7 @@ AI_NODES = [ "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, - {"name": "targetFormat", "type": "string", "required": True, "frontendType": "select", + {"name": "targetFormat", "type": "str", "required": True, "frontendType": "select", "frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]}, "description": t("Zielformat")}, ] + _AI_COMMON_PARAMS, @@ -126,12 +130,16 @@ AI_NODES = [ "label": t("Dokument generieren"), "description": t("Dokument aus Prompt generieren"), "parameters": [ - {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", + {"name": "prompt", "type": "str", "required": True, "frontendType": "textarea", "description": t("Generierungs-Prompt")}, + {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", + "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", + "description": t("Optionale Dokumentenliste als Vorlage/Referenz"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit"]}}, + "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -143,15 +151,19 @@ AI_NODES = [ "label": t("Code generieren"), "description": t("Code aus Beschreibung generieren"), "parameters": [ - {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", + {"name": "prompt", "type": "str", "required": True, "frontendType": "textarea", "description": t("Code-Generierungs-Prompt")}, - {"name": "resultType", "type": "string", "required": False, "frontendType": "select", + {"name": "resultType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]}, "description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"}, + {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", + "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", + "description": t("Optionale Dokumentenliste als Referenz"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit"]}}, + "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -163,10 +175,10 @@ AI_NODES = [ "label": t("KI-Konsolidierung"), "description": t("Gesammelte Ergebnisse mit KI zusammenfassen, klassifizieren oder semantisch zusammenführen"), "parameters": [ - {"name": "mode", "type": "string", "required": False, "frontendType": "select", + {"name": "mode", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["summarize", "classify", "semanticMerge"]}, "description": t("Konsolidierungsmodus"), "default": "summarize"}, - {"name": "prompt", "type": "string", "required": False, "frontendType": "textarea", + {"name": "prompt", "type": "str", "required": False, "frontendType": "textarea", "description": t("Optionaler Prompt für die Konsolidierung"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/clickup.py b/modules/features/graphicalEditor/nodeDefinitions/clickup.py index 56b27984..53b75d4b 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/clickup.py +++ b/modules/features/graphicalEditor/nodeDefinitions/clickup.py @@ -11,23 +11,23 @@ CLICKUP_NODES = [ "label": t("Aufgaben suchen"), "description": t("Aufgaben in einem Workspace suchen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, - {"name": "teamId", "type": "string", "required": True, "frontendType": "text", + {"name": "teamId", "type": "str", "required": True, "frontendType": "text", "description": t("Team-/Workspace-ID")}, - {"name": "query", "type": "string", "required": True, "frontendType": "text", + {"name": "query", "type": "str", "required": True, "frontendType": "text", "description": t("Suchbegriff")}, - {"name": "page", "type": "number", "required": False, "frontendType": "number", + {"name": "page", "type": "int", "required": False, "frontendType": "number", "description": t("Seite"), "default": 0}, - {"name": "listId", "type": "string", "required": False, "frontendType": "clickupList", + {"name": "listId", "type": "str", "required": False, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("In dieser Liste suchen")}, - {"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Erledigte einbeziehen"), "default": False}, - {"name": "fullTaskData", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "fullTaskData", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Vollständige Daten"), "default": False}, - {"name": "matchNameOnly", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "matchNameOnly", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Nur Titel"), "default": True}, ], "inputs": 1, @@ -44,15 +44,15 @@ CLICKUP_NODES = [ "label": t("Aufgaben auflisten"), "description": t("Aufgaben einer Liste auflisten"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, - {"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList", + {"name": "pathQuery", "type": "str", "required": True, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("Pfad zur Liste")}, - {"name": "page", "type": "number", "required": False, "frontendType": "number", + {"name": "page", "type": "int", "required": False, "frontendType": "number", "description": t("Seite"), "default": 0}, - {"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Erledigte einbeziehen"), "default": False}, ], "inputs": 1, @@ -69,12 +69,12 @@ CLICKUP_NODES = [ "label": t("Aufgabe abrufen"), "description": t("Eine Aufgabe abrufen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, - {"name": "taskId", "type": "string", "required": False, "frontendType": "text", + {"name": "taskId", "type": "str", "required": False, "frontendType": "text", "description": t("Task-ID")}, - {"name": "pathQuery", "type": "string", "required": False, "frontendType": "text", + {"name": "pathQuery", "type": "str", "required": False, "frontendType": "text", "description": t("Oder Pfad")}, ], "inputs": 1, @@ -91,34 +91,34 @@ CLICKUP_NODES = [ "label": t("Aufgabe erstellen"), "description": t("Aufgabe erstellen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, - {"name": "pathQuery", "type": "string", "required": False, "frontendType": "clickupList", + {"name": "pathQuery", "type": "str", "required": False, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("Pfad zur Liste")}, - {"name": "listId", "type": "string", "required": False, "frontendType": "text", + {"name": "listId", "type": "str", "required": False, "frontendType": "text", "description": t("Listen-ID")}, - {"name": "name", "type": "string", "required": True, "frontendType": "text", + {"name": "name", "type": "str", "required": True, "frontendType": "text", "description": t("Name")}, - {"name": "description", "type": "string", "required": False, "frontendType": "textarea", + {"name": "description", "type": "str", "required": False, "frontendType": "textarea", "description": t("Beschreibung")}, - {"name": "taskStatus", "type": "string", "required": False, "frontendType": "text", + {"name": "taskStatus", "type": "str", "required": False, "frontendType": "text", "description": t("Status")}, - {"name": "taskPriority", "type": "string", "required": False, "frontendType": "select", + {"name": "taskPriority", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["1", "2", "3", "4"]}, "description": t("Priorität 1-4")}, - {"name": "taskDueDateMs", "type": "string", "required": False, "frontendType": "text", + {"name": "taskDueDateMs", "type": "str", "required": False, "frontendType": "text", "description": t("Fälligkeit (ms)")}, {"name": "taskAssigneeIds", "type": "object", "required": False, "frontendType": "json", "description": t("Zugewiesene")}, - {"name": "taskTimeEstimateMs", "type": "string", "required": False, "frontendType": "text", + {"name": "taskTimeEstimateMs", "type": "str", "required": False, "frontendType": "text", "description": t("Zeitschätzung (ms)")}, - {"name": "taskTimeEstimateHours", "type": "string", "required": False, "frontendType": "text", + {"name": "taskTimeEstimateHours", "type": "str", "required": False, "frontendType": "text", "description": t("Zeitschätzung (h)")}, {"name": "customFieldValues", "type": "object", "required": False, "frontendType": "json", "description": t("Benutzerdefinierte Felder")}, - {"name": "taskFields", "type": "string", "required": False, "frontendType": "json", + {"name": "taskFields", "type": "str", "required": False, "frontendType": "json", "description": t("Zusätzliches JSON")}, ], "inputs": 1, @@ -135,14 +135,14 @@ CLICKUP_NODES = [ "label": t("Aufgabe aktualisieren"), "description": t("Felder der Aufgabe ändern"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, - {"name": "taskId", "type": "string", "required": False, "frontendType": "text", + {"name": "taskId", "type": "str", "required": False, "frontendType": "text", "description": t("Task-ID")}, - {"name": "path", "type": "string", "required": False, "frontendType": "text", + {"name": "path", "type": "str", "required": False, "frontendType": "text", "description": t("Oder Pfad")}, - {"name": "taskUpdate", "type": "string", "required": False, "frontendType": "json", + {"name": "taskUpdate", "type": "str", "required": False, "frontendType": "json", "description": t("JSON-Body für PUT /task/{id}, z.B. {\"name\":\"...\",\"status\":\"...\"}")}, ], "inputs": 1, @@ -159,16 +159,16 @@ CLICKUP_NODES = [ "label": t("Anhang hochladen"), "description": t("Datei an Task anhängen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, - {"name": "taskId", "type": "string", "required": False, "frontendType": "text", + {"name": "taskId", "type": "str", "required": False, "frontendType": "text", "description": t("Task-ID")}, - {"name": "path", "type": "string", "required": False, "frontendType": "text", + {"name": "path", "type": "str", "required": False, "frontendType": "text", "description": t("Oder Pfad")}, - {"name": "fileName", "type": "string", "required": False, "frontendType": "text", + {"name": "fileName", "type": "str", "required": False, "frontendType": "text", "description": t("Dateiname")}, - {"name": "content", "type": "string", "required": True, "frontendType": "hidden", + {"name": "content", "type": "str", "required": True, "frontendType": "hidden", "description": t("Datei-Inhalt aus Upstream-Node (via Wire oder DataRef)"), "default": ""}, ], "inputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/context.py b/modules/features/graphicalEditor/nodeDefinitions/context.py index 81d878be..f6757cc8 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/context.py +++ b/modules/features/graphicalEditor/nodeDefinitions/context.py @@ -10,7 +10,7 @@ CONTEXT_NODES = [ "label": t("Inhalt extrahieren"), "description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"), "parameters": [ - {"name": "documentList", "type": "string", "required": True, "frontendType": "hidden", + {"name": "documentList", "type": "str", "required": True, "frontendType": "hidden", "description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""}, {"name": "extractionOptions", "type": "object", "required": False, "frontendType": "json", "description": t( diff --git a/modules/features/graphicalEditor/nodeDefinitions/data.py b/modules/features/graphicalEditor/nodeDefinitions/data.py index b6208840..ca1f9035 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/data.py +++ b/modules/features/graphicalEditor/nodeDefinitions/data.py @@ -10,7 +10,7 @@ DATA_NODES = [ "label": t("Sammeln"), "description": t("Ergebnisse aus Schleifen-Iterationen sammeln"), "parameters": [ - {"name": "mode", "type": "string", "required": False, "frontendType": "select", + {"name": "mode", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["collect", "concat", "sum", "count"]}, "description": t("Aggregationsmodus"), "default": "collect"}, ], @@ -27,9 +27,9 @@ DATA_NODES = [ "label": t("Filtern"), "description": t("Elemente nach Bedingung filtern"), "parameters": [ - {"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression", + {"name": "condition", "type": "str", "required": True, "frontendType": "filterExpression", "description": t("Filterbedingung")}, - {"name": "udmContentType", "type": "string", "required": False, "frontendType": "select", + {"name": "udmContentType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["", "text", "image", "table", "code", "media", "link", "formula"]}, "description": t("UDM-ContentType-Filter (optional, leer = kein UDM-Filter)"), "default": ""}, ], @@ -46,10 +46,10 @@ DATA_NODES = [ "label": t("Konsolidieren"), "description": t("Gesammelte Ergebnisse deterministisch zusammenführen (Tabelle, CSV, Merge)"), "parameters": [ - {"name": "mode", "type": "string", "required": False, "frontendType": "select", + {"name": "mode", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["table", "concat", "merge", "csvJoin"]}, "description": t("Konsolidierungsmodus"), "default": "table"}, - {"name": "separator", "type": "string", "required": False, "frontendType": "text", + {"name": "separator", "type": "str", "required": False, "frontendType": "text", "description": t("Trennzeichen (für concat/csvJoin)"), "default": "\n"}, ], "inputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/email.py b/modules/features/graphicalEditor/nodeDefinitions/email.py index 270b8d63..698efa94 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/email.py +++ b/modules/features/graphicalEditor/nodeDefinitions/email.py @@ -10,14 +10,14 @@ EMAIL_NODES = [ "label": t("E-Mail prüfen"), "description": t("Neue E-Mails prüfen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("E-Mail-Konto Verbindung")}, - {"name": "folder", "type": "string", "required": False, "frontendType": "text", + {"name": "folder", "type": "str", "required": False, "frontendType": "text", "description": t("Ordner"), "default": "Inbox"}, - {"name": "limit", "type": "number", "required": False, "frontendType": "number", + {"name": "limit", "type": "int", "required": False, "frontendType": "number", "description": t("Max E-Mails"), "default": 100}, - {"name": "filter", "type": "string", "required": False, "frontendType": "text", + {"name": "filter", "type": "str", "required": False, "frontendType": "text", "description": t("Filter-Ausdruck (z.B. 'from:max@example.com hasAttachment:true betreff')"), "default": ""}, ], "inputs": 1, @@ -34,14 +34,14 @@ EMAIL_NODES = [ "label": t("E-Mail suchen"), "description": t("E-Mails suchen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("E-Mail-Konto Verbindung")}, - {"name": "query", "type": "string", "required": True, "frontendType": "text", + {"name": "query", "type": "str", "required": True, "frontendType": "text", "description": t("Suchausdruck (z.B. 'from:max@example.com hasAttachments:true Rechnung')")}, - {"name": "folder", "type": "string", "required": False, "frontendType": "text", + {"name": "folder", "type": "str", "required": False, "frontendType": "text", "description": t("Ordner"), "default": "All"}, - {"name": "limit", "type": "number", "required": False, "frontendType": "number", + {"name": "limit", "type": "int", "required": False, "frontendType": "number", "description": t("Max E-Mails"), "default": 100}, ], "inputs": 1, @@ -59,19 +59,19 @@ EMAIL_NODES = [ "description": t( "AI-gestützt einen E-Mail-Entwurf aus Kontext und optionalen Dokumenten erstellen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("E-Mail-Konto")}, - {"name": "context", "type": "string", "required": False, "frontendType": "templateTextarea", + {"name": "context", "type": "str", "required": False, "frontendType": "templateTextarea", "description": t("Kontext / Brief-Beschreibung für die KI-Komposition"), "default": ""}, - {"name": "to", "type": "string", "required": False, "frontendType": "text", + {"name": "to", "type": "str", "required": False, "frontendType": "text", "description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""}, - {"name": "documentList", "type": "string", "required": False, "frontendType": "hidden", + {"name": "documentList", "type": "str", "required": False, "frontendType": "hidden", "description": t("Anhang-Dokumente (via Wire oder DataRef)"), "default": ""}, - {"name": "emailContent", "type": "string", "required": False, "frontendType": "hidden", + {"name": "emailContent", "type": "str", "required": False, "frontendType": "hidden", "description": t("Direkt vorbereiteter Inhalt {subject, body, to} (via Wire — überspringt KI)"), "default": ""}, - {"name": "emailStyle", "type": "string", "required": False, "frontendType": "select", + {"name": "emailStyle", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["formal", "casual", "business"]}, "description": t("Stil"), "default": "business"}, ], diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index 8e04f2bc..9fbd261d 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -12,19 +12,19 @@ FILE_NODES = [ "parameters": [ {"name": "contentSources", "type": "json", "required": False, "frontendType": "json", "description": t("Kontext-Quellen"), "default": []}, - {"name": "outputFormat", "type": "string", "required": True, "frontendType": "select", + {"name": "outputFormat", "type": "str", "required": True, "frontendType": "select", "frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]}, "description": t("Ausgabeformat"), "default": "docx"}, - {"name": "title", "type": "string", "required": False, "frontendType": "text", + {"name": "title", "type": "str", "required": False, "frontendType": "text", "description": t("Dokumenttitel")}, - {"name": "templateName", "type": "string", "required": False, "frontendType": "select", + {"name": "templateName", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["default", "corporate", "minimal"]}, "description": t("Stil-Vorlage")}, - {"name": "language", "type": "string", "required": False, "frontendType": "select", + {"name": "language", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["de", "en", "fr"]}, "description": t("Sprache"), "default": "de"}, - {"name": "context", "type": "string", "required": False, "frontendType": "hidden", - "description": t("Inhalt (via Wire oder DataRef)"), "default": ""}, + {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", + "description": t("Inhalt aus Upstream-Node (binden via DataRef oder Wire)"), "default": ""}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/flow.py b/modules/features/graphicalEditor/nodeDefinitions/flow.py index 04a44197..5ebf50bc 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/flow.py +++ b/modules/features/graphicalEditor/nodeDefinitions/flow.py @@ -12,7 +12,7 @@ FLOW_NODES = [ "parameters": [ { "name": "condition", - "type": "string", + "type": "str", "required": True, "frontendType": "condition", "description": t("Bedingung"), @@ -34,7 +34,7 @@ FLOW_NODES = [ "parameters": [ { "name": "value", - "type": "string", + "type": "str", "required": True, "frontendType": "text", "description": t("Zu vergleichender Wert"), @@ -62,14 +62,14 @@ FLOW_NODES = [ "parameters": [ { "name": "items", - "type": "string", + "type": "str", "required": True, "frontendType": "text", "description": t("Pfad zum Array"), }, { "name": "level", - "type": "string", + "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["auto", "documents", "structuralNodes", "contentBlocks"]}, @@ -78,7 +78,7 @@ FLOW_NODES = [ }, { "name": "concurrency", - "type": "number", + "type": "int", "required": False, "frontendType": "number", "frontendOptions": {"min": 1, "max": 20}, @@ -103,7 +103,7 @@ FLOW_NODES = [ "parameters": [ { "name": "mode", - "type": "string", + "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["first", "all", "append"]}, @@ -112,7 +112,7 @@ FLOW_NODES = [ }, { "name": "inputCount", - "type": "number", + "type": "int", "required": False, "frontendType": "number", "frontendOptions": {"min": 2, "max": 5}, diff --git a/modules/features/graphicalEditor/nodeDefinitions/input.py b/modules/features/graphicalEditor/nodeDefinitions/input.py index 647e9ac2..e2d0271a 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/input.py +++ b/modules/features/graphicalEditor/nodeDefinitions/input.py @@ -3,6 +3,18 @@ from modules.shared.i18nRegistry import t +# Canonical form field types — single source of truth. +# portType maps to the PORT_TYPE_CATALOG primitive used by DataPicker / validateGraph. +FORM_FIELD_TYPES = [ + {"id": "text", "label": "Text (einzeilig)", "portType": "str"}, + {"id": "textarea", "label": "Text (mehrzeilig)", "portType": "str"}, + {"id": "number", "label": "Zahl", "portType": "int"}, + {"id": "boolean", "label": "Ja/Nein", "portType": "bool"}, + {"id": "date", "label": "Datum", "portType": "str"}, + {"id": "email", "label": "E-Mail", "portType": "str"}, + {"id": "select", "label": "Auswahl", "portType": "str"}, +] + INPUT_NODES = [ { "id": "input.form", @@ -32,11 +44,11 @@ INPUT_NODES = [ "label": t("Genehmigung"), "description": t("Benutzer genehmigt oder lehnt ab"), "parameters": [ - {"name": "title", "type": "string", "required": True, "frontendType": "text", + {"name": "title", "type": "str", "required": True, "frontendType": "text", "description": t("Genehmigungstitel")}, - {"name": "description", "type": "string", "required": False, "frontendType": "textarea", + {"name": "description", "type": "str", "required": False, "frontendType": "textarea", "description": t("Was genehmigt werden soll")}, - {"name": "approvalType", "type": "string", "required": False, "frontendType": "select", + {"name": "approvalType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["generic", "document"]}, "description": t("Typ: document oder generic"), "default": "generic"}, ], @@ -53,14 +65,14 @@ INPUT_NODES = [ "label": t("Upload"), "description": t("Benutzer lädt Datei(en) hoch"), "parameters": [ - {"name": "accept", "type": "string", "required": False, "frontendType": "text", + {"name": "accept", "type": "str", "required": False, "frontendType": "text", "description": t("Accept-String"), "default": ""}, {"name": "allowedTypes", "type": "json", "required": False, "frontendType": "multiselect", "frontendOptions": {"options": ["pdf", "docx", "xlsx", "pptx", "txt", "csv", "jpg", "png", "gif"]}, "description": t("Ausgewählte Dateitypen"), "default": []}, - {"name": "maxSize", "type": "number", "required": False, "frontendType": "number", + {"name": "maxSize", "type": "int", "required": False, "frontendType": "number", "description": t("Max. Dateigröße in MB"), "default": 10}, - {"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "multiple", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Mehrere Dateien erlauben"), "default": False}, ], "inputs": 1, @@ -76,9 +88,9 @@ INPUT_NODES = [ "label": t("Kommentar"), "description": t("Benutzer fügt einen Kommentar hinzu"), "parameters": [ - {"name": "placeholder", "type": "string", "required": False, "frontendType": "text", + {"name": "placeholder", "type": "str", "required": False, "frontendType": "text", "description": t("Platzhalter"), "default": ""}, - {"name": "required", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "required", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Kommentar erforderlich"), "default": True}, ], "inputs": 1, @@ -94,9 +106,9 @@ INPUT_NODES = [ "label": t("Prüfung"), "description": t("Benutzer prüft Inhalt"), "parameters": [ - {"name": "contentRef", "type": "string", "required": True, "frontendType": "text", + {"name": "contentRef", "type": "str", "required": True, "frontendType": "text", "description": t("Referenz auf Inhalt")}, - {"name": "reviewType", "type": "string", "required": False, "frontendType": "select", + {"name": "reviewType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["generic", "document"]}, "description": t("Art der Prüfung"), "default": "generic"}, ], @@ -115,7 +127,7 @@ INPUT_NODES = [ "parameters": [ {"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows", "description": t("Optionen"), "default": []}, - {"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "multiple", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Mehrfachauswahl erlauben"), "default": False}, ], "inputs": 1, @@ -131,11 +143,11 @@ INPUT_NODES = [ "label": t("Bestätigung"), "description": t("Benutzer bestätigt Ja/Nein"), "parameters": [ - {"name": "question", "type": "string", "required": True, "frontendType": "text", + {"name": "question", "type": "str", "required": True, "frontendType": "text", "description": t("Zu bestätigende Frage")}, - {"name": "confirmLabel", "type": "string", "required": False, "frontendType": "text", + {"name": "confirmLabel", "type": "str", "required": False, "frontendType": "text", "description": t("Label für Bestätigen-Button"), "default": "Confirm"}, - {"name": "rejectLabel", "type": "string", "required": False, "frontendType": "text", + {"name": "rejectLabel", "type": "str", "required": False, "frontendType": "text", "description": t("Label für Ablehnen-Button"), "default": "Reject"}, ], "inputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/redmine.py b/modules/features/graphicalEditor/nodeDefinitions/redmine.py index d9ea8bab..2d8ebb59 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/redmine.py +++ b/modules/features/graphicalEditor/nodeDefinitions/redmine.py @@ -25,7 +25,7 @@ REDMINE_NODES = [ "description": t("Einzelnes Redmine-Ticket aus dem Mirror laden."), "parameters": [ dict(_REDMINE_INSTANCE_PARAM), - {"name": "ticketId", "type": "number", "required": True, "frontendType": "number", + {"name": "ticketId", "type": "int", "required": True, "frontendType": "number", "description": t("Redmine-Ticket-ID")}, ], "inputs": 1, @@ -43,17 +43,17 @@ REDMINE_NODES = [ "description": t("Tickets aus dem lokalen Mirror mit Filtern (Tracker, Status, Zeitraum, Zuweisung)."), "parameters": [ dict(_REDMINE_INSTANCE_PARAM), - {"name": "trackerIds", "type": "string", "required": False, "frontendType": "text", + {"name": "trackerIds", "type": "str", "required": False, "frontendType": "text", "description": t("Tracker-IDs (Komma-separiert)"), "default": ""}, - {"name": "status", "type": "string", "required": False, "frontendType": "text", + {"name": "status", "type": "str", "required": False, "frontendType": "text", "description": t("Status-Filter: open | closed | *"), "default": "*"}, - {"name": "dateFrom", "type": "string", "required": False, "frontendType": "date", + {"name": "dateFrom", "type": "str", "required": False, "frontendType": "date", "description": t("Zeitraum ab (ISO-Datum)"), "default": ""}, - {"name": "dateTo", "type": "string", "required": False, "frontendType": "date", + {"name": "dateTo", "type": "str", "required": False, "frontendType": "date", "description": t("Zeitraum bis (ISO-Datum)"), "default": ""}, - {"name": "assignedToId", "type": "number", "required": False, "frontendType": "number", + {"name": "assignedToId", "type": "int", "required": False, "frontendType": "number", "description": t("Nur Tickets dieses Benutzers (ID)")}, - {"name": "limit", "type": "number", "required": False, "frontendType": "number", + {"name": "limit", "type": "int", "required": False, "frontendType": "number", "description": t("Max. Anzahl Tickets (1-500)"), "default": 100}, ], "inputs": 1, @@ -71,21 +71,21 @@ REDMINE_NODES = [ "description": t("Neues Ticket in Redmine anlegen. Mirror wird sofort aktualisiert."), "parameters": [ dict(_REDMINE_INSTANCE_PARAM), - {"name": "subject", "type": "string", "required": True, "frontendType": "text", + {"name": "subject", "type": "str", "required": True, "frontendType": "text", "description": t("Ticket-Titel")}, - {"name": "trackerId", "type": "number", "required": True, "frontendType": "number", + {"name": "trackerId", "type": "int", "required": True, "frontendType": "number", "description": t("Tracker-ID (Userstory, Feature, Task, ...)")}, - {"name": "description", "type": "string", "required": False, "frontendType": "textarea", + {"name": "description", "type": "str", "required": False, "frontendType": "textarea", "description": t("Ticket-Beschreibung"), "default": ""}, - {"name": "statusId", "type": "number", "required": False, "frontendType": "number", + {"name": "statusId", "type": "int", "required": False, "frontendType": "number", "description": t("Status-ID (optional)")}, - {"name": "priorityId", "type": "number", "required": False, "frontendType": "number", + {"name": "priorityId", "type": "int", "required": False, "frontendType": "number", "description": t("Prioritaet-ID (optional)")}, - {"name": "assignedToId", "type": "number", "required": False, "frontendType": "number", + {"name": "assignedToId", "type": "int", "required": False, "frontendType": "number", "description": t("Zugewiesene Benutzer-ID (optional)")}, - {"name": "parentIssueId", "type": "number", "required": False, "frontendType": "number", + {"name": "parentIssueId", "type": "int", "required": False, "frontendType": "number", "description": t("Uebergeordnetes Ticket (optional)")}, - {"name": "customFields", "type": "string", "required": False, "frontendType": "textarea", + {"name": "customFields", "type": "str", "required": False, "frontendType": "textarea", "description": t("Custom Fields als JSON {id: value}"), "default": ""}, ], "inputs": 1, @@ -103,25 +103,25 @@ REDMINE_NODES = [ "description": t("Felder eines Redmine-Tickets aktualisieren. Nur gesetzte Felder werden uebertragen."), "parameters": [ dict(_REDMINE_INSTANCE_PARAM), - {"name": "ticketId", "type": "number", "required": True, "frontendType": "number", + {"name": "ticketId", "type": "int", "required": True, "frontendType": "number", "description": t("Ticket-ID")}, - {"name": "subject", "type": "string", "required": False, "frontendType": "text", + {"name": "subject", "type": "str", "required": False, "frontendType": "text", "description": t("Neuer Titel")}, - {"name": "description", "type": "string", "required": False, "frontendType": "textarea", + {"name": "description", "type": "str", "required": False, "frontendType": "textarea", "description": t("Neue Beschreibung")}, - {"name": "trackerId", "type": "number", "required": False, "frontendType": "number", + {"name": "trackerId", "type": "int", "required": False, "frontendType": "number", "description": t("Neuer Tracker")}, - {"name": "statusId", "type": "number", "required": False, "frontendType": "number", + {"name": "statusId", "type": "int", "required": False, "frontendType": "number", "description": t("Neuer Status")}, - {"name": "priorityId", "type": "number", "required": False, "frontendType": "number", + {"name": "priorityId", "type": "int", "required": False, "frontendType": "number", "description": t("Neue Prioritaet")}, - {"name": "assignedToId", "type": "number", "required": False, "frontendType": "number", + {"name": "assignedToId", "type": "int", "required": False, "frontendType": "number", "description": t("Neue Zuweisung")}, - {"name": "parentIssueId", "type": "number", "required": False, "frontendType": "number", + {"name": "parentIssueId", "type": "int", "required": False, "frontendType": "number", "description": t("Neues Parent-Ticket")}, - {"name": "notes", "type": "string", "required": False, "frontendType": "textarea", + {"name": "notes", "type": "str", "required": False, "frontendType": "textarea", "description": t("Kommentar (Journal-Eintrag)"), "default": ""}, - {"name": "customFields", "type": "string", "required": False, "frontendType": "textarea", + {"name": "customFields", "type": "str", "required": False, "frontendType": "textarea", "description": t("Custom Fields als JSON {id: value}"), "default": ""}, ], "inputs": 1, @@ -139,13 +139,13 @@ REDMINE_NODES = [ "description": t("Aggregierte Kennzahlen (KPIs, Durchsatz, Status-Verteilung, Backlog) aus dem Mirror."), "parameters": [ dict(_REDMINE_INSTANCE_PARAM), - {"name": "dateFrom", "type": "string", "required": False, "frontendType": "date", + {"name": "dateFrom", "type": "str", "required": False, "frontendType": "date", "description": t("Zeitraum ab")}, - {"name": "dateTo", "type": "string", "required": False, "frontendType": "date", + {"name": "dateTo", "type": "str", "required": False, "frontendType": "date", "description": t("Zeitraum bis")}, - {"name": "bucket", "type": "string", "required": False, "frontendType": "text", + {"name": "bucket", "type": "str", "required": False, "frontendType": "text", "description": t("Bucket: day | week | month"), "default": "week"}, - {"name": "trackerIds", "type": "string", "required": False, "frontendType": "text", + {"name": "trackerIds", "type": "str", "required": False, "frontendType": "text", "description": t("Tracker-IDs (Komma-separiert)"), "default": ""}, ], "inputs": 1, @@ -163,7 +163,7 @@ REDMINE_NODES = [ "description": t("Tickets und Beziehungen aus Redmine in den lokalen Mirror uebernehmen."), "parameters": [ dict(_REDMINE_INSTANCE_PARAM), - {"name": "force", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "force", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Vollsync erzwingen (ignoriert lastSyncAt)"), "default": False}, ], "inputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py b/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py index 7e52ef8d..b47a6b54 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py +++ b/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py @@ -10,14 +10,14 @@ SHAREPOINT_NODES = [ "label": t("Datei finden"), "description": t("Datei nach Pfad oder Suche finden"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, - {"name": "searchQuery", "type": "string", "required": True, "frontendType": "text", + {"name": "searchQuery", "type": "str", "required": True, "frontendType": "text", "description": t("Suchanfrage oder Pfad")}, - {"name": "site", "type": "string", "required": False, "frontendType": "text", + {"name": "site", "type": "str", "required": False, "frontendType": "text", "description": t("Optionaler Site-Hinweis"), "default": ""}, - {"name": "maxResults", "type": "number", "required": False, "frontendType": "number", + {"name": "maxResults", "type": "int", "required": False, "frontendType": "number", "description": t("Max Ergebnisse"), "default": 1000}, ], "inputs": 1, @@ -34,10 +34,10 @@ SHAREPOINT_NODES = [ "label": t("Datei lesen"), "description": t("Inhalt aus Datei extrahieren"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, - {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile", + {"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("Dateipfad")}, ], @@ -55,13 +55,13 @@ SHAREPOINT_NODES = [ "label": t("Datei hochladen"), "description": t("Datei zu SharePoint hochladen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, - {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder", + {"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("Zielordner-Pfad")}, - {"name": "content", "type": "string", "required": True, "frontendType": "hidden", + {"name": "content", "type": "str", "required": True, "frontendType": "hidden", "description": t("Datei-Inhalt aus Upstream-Node (via Wire oder DataRef)"), "default": ""}, ], "inputs": 1, @@ -78,10 +78,10 @@ SHAREPOINT_NODES = [ "label": t("Dateien auflisten"), "description": t("Dateien in Ordner auflisten"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, - {"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder", + {"name": "pathQuery", "type": "str", "required": False, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("Ordnerpfad"), "default": "/"}, ], @@ -99,10 +99,10 @@ SHAREPOINT_NODES = [ "label": t("Datei herunterladen"), "description": t("Datei vom Pfad herunterladen"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, - {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile", + {"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("Vollständiger Dateipfad")}, ], @@ -120,13 +120,13 @@ SHAREPOINT_NODES = [ "label": t("Datei kopieren"), "description": t("Datei an Ziel kopieren"), "parameters": [ - {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung")}, - {"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile", + {"name": "sourcePath", "type": "str", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("Quelldatei-Pfad")}, - {"name": "destPath", "type": "string", "required": True, "frontendType": "sharepointFolder", + {"name": "destPath", "type": "str", "required": True, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("Zielordner")}, ], diff --git a/modules/features/graphicalEditor/nodeDefinitions/triggers.py b/modules/features/graphicalEditor/nodeDefinitions/triggers.py index 7b55d5d7..443f8c02 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/triggers.py +++ b/modules/features/graphicalEditor/nodeDefinitions/triggers.py @@ -46,7 +46,7 @@ TRIGGER_NODES = [ "parameters": [ { "name": "cron", - "type": "string", + "type": "str", "required": False, "frontendType": "cron", "description": t("Cron-Ausdruck"), diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/features/graphicalEditor/nodeDefinitions/trustee.py index 0a8e7cd7..7392b4d2 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/trustee.py +++ b/modules/features/graphicalEditor/nodeDefinitions/trustee.py @@ -25,11 +25,11 @@ TRUSTEE_NODES = [ "description": t("Buchhaltungsdaten aus externem System importieren/aktualisieren."), "parameters": [ dict(_TRUSTEE_INSTANCE_PARAM), - {"name": "forceRefresh", "type": "boolean", "required": False, "frontendType": "checkbox", + {"name": "forceRefresh", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Import erzwingen"), "default": False}, - {"name": "dateFrom", "type": "string", "required": False, "frontendType": "date", + {"name": "dateFrom", "type": "str", "required": False, "frontendType": "date", "description": t("Startdatum"), "default": ""}, - {"name": "dateTo", "type": "string", "required": False, "frontendType": "date", + {"name": "dateTo", "type": "str", "required": False, "frontendType": "date", "description": t("Enddatum"), "default": ""}, ], "inputs": 1, @@ -46,14 +46,14 @@ TRUSTEE_NODES = [ "label": t("Dokumente extrahieren"), "description": t("Dokumenttyp und Daten aus PDF/JPG per AI extrahieren."), "parameters": [ - {"name": "connectionReference", "type": "string", "required": False, "frontendType": "userConnection", + {"name": "connectionReference", "type": "str", "required": False, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("SharePoint-Verbindung"), "default": ""}, - {"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder", + {"name": "sharepointFolder", "type": "str", "required": False, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, "description": t("SharePoint-Ordnerpfad"), "default": ""}, dict(_TRUSTEE_INSTANCE_PARAM), - {"name": "prompt", "type": "string", "required": False, "frontendType": "textarea", + {"name": "prompt", "type": "str", "required": False, "frontendType": "textarea", "description": t("AI-Prompt für Extraktion"), "default": ""}, ], "inputs": 1, @@ -113,25 +113,25 @@ TRUSTEE_NODES = [ "description": t("Daten aus der Trustee-DB lesen (Lookup, Aggregation, Roh-Export). Pendant zu refreshAccountingData ohne externen Sync."), "parameters": [ dict(_TRUSTEE_INSTANCE_PARAM), - {"name": "mode", "type": "string", "required": True, "frontendType": "select", + {"name": "mode", "type": "str", "required": True, "frontendType": "select", "frontendOptions": {"options": ["lookup", "raw", "aggregate"]}, "description": t("Abfragemodus"), "default": "lookup"}, - {"name": "entity", "type": "string", "required": True, "frontendType": "select", + {"name": "entity", "type": "str", "required": True, "frontendType": "select", "frontendOptions": {"options": ["tenantWithRent", "contact", "journalLines", "accounts", "balances"]}, "description": t("Entität, die gelesen werden soll"), "default": "tenantWithRent"}, - {"name": "tenantNameRef", "type": "string", "required": False, "frontendType": "text", + {"name": "tenantNameRef", "type": "str", "required": False, "frontendType": "text", "frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "contact"]}, "description": t("Mietername (oder {{wire.feld}} aus Upstream)"), "default": ""}, - {"name": "tenantAddressRef", "type": "string", "required": False, "frontendType": "text", + {"name": "tenantAddressRef", "type": "str", "required": False, "frontendType": "text", "frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "contact"]}, "description": t("Mieteradresse (Toleranz für Tippfehler)"), "default": ""}, - {"name": "period", "type": "string", "required": False, "frontendType": "text", + {"name": "period", "type": "str", "required": False, "frontendType": "text", "frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "journalLines", "balances"]}, "description": t("Zeitraum (YYYY oder YYYY-MM-DD/YYYY-MM-DD)"), "default": ""}, - {"name": "rentAccountPattern", "type": "string", "required": False, "frontendType": "text", + {"name": "rentAccountPattern", "type": "str", "required": False, "frontendType": "text", "frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent"]}, "description": t("Konto-Filter für Mietzins (z.B. '6000-6099' oder '6*')"), "default": ""}, - {"name": "filterJson", "type": "string", "required": False, "frontendType": "textarea", + {"name": "filterJson", "type": "str", "required": False, "frontendType": "textarea", "frontendOptions": {"dependsOn": "mode", "showWhen": ["raw", "aggregate"]}, "description": t("Optionaler JSON-Filter für mode=raw/aggregate"), "default": ""}, ], diff --git a/modules/features/graphicalEditor/nodeRegistry.py b/modules/features/graphicalEditor/nodeRegistry.py index 632e98fc..a3c8bd0b 100644 --- a/modules/features/graphicalEditor/nodeRegistry.py +++ b/modules/features/graphicalEditor/nodeRegistry.py @@ -9,6 +9,7 @@ import logging from typing import Dict, List, Any, Optional 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.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText @@ -119,6 +120,7 @@ def getNodeTypesForApi( "categories": categories, "portTypeCatalog": catalogSerialized, "systemVariables": SYSTEM_VARIABLES, + "formFieldTypes": FORM_FIELD_TYPES, } diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index f1513f9e..246d4791 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -740,6 +740,9 @@ def _resolveTransitChain( def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Optional[PortSchema]: """Derive output schema from a field-builder JSON list (``fields``, ``formFields``, …).""" + from modules.features.graphicalEditor.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) if not fields_param or not isinstance(fields_param, list): return None @@ -749,9 +752,11 @@ def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Op _desc = resolveText(lab) if lab is not None else fname if not str(_desc).strip(): _desc = fname + raw_type = str(ftype) if ftype is not None else "str" + port_type = _FORM_TYPE_TO_PORT.get(raw_type, raw_type) portFields.append(PortField( name=fname, - type=str(ftype) if ftype is not None else "str", + type=port_type, description=_desc, required=required, )) diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py index 5ec6b51d..24237289 100644 --- a/modules/workflows/methods/methodAi/actions/generateCode.py +++ b/modules/workflows/methods/methodAi/actions/generateCode.py @@ -14,8 +14,13 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil logger = logging.getLogger(__name__) async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult: - prompt = parameters.get("prompt") - if not prompt: + base_prompt = parameters.get("prompt") or "" + context_val = parameters.get("context") + if context_val and isinstance(context_val, str) and context_val.strip(): + prompt = f"Kontext:\n{context_val.strip()}\n\n{base_prompt.strip()}" + else: + prompt = base_prompt + if not prompt.strip(): return ActionResult.isFailure(error="prompt is required") documentList = parameters.get("documentList", []) diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 18c158c1..1d4a4b66 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -14,8 +14,13 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil logger = logging.getLogger(__name__) async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: - prompt = parameters.get("prompt") - if not prompt: + base_prompt = parameters.get("prompt") or "" + context_val = parameters.get("context") + if context_val and isinstance(context_val, str) and context_val.strip(): + prompt = f"Kontext:\n{context_val.strip()}\n\n{base_prompt.strip()}" + else: + prompt = base_prompt + if not prompt.strip(): return ActionResult.isFailure(error="prompt is required") documentList = parameters.get("documentList", []) diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py index 2c873396..2cf388b9 100644 --- a/modules/workflows/methods/methodAi/actions/webResearch.py +++ b/modules/workflows/methods/methodAi/actions/webResearch.py @@ -13,10 +13,42 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil logger = logging.getLogger(__name__) +def _build_research_prompt(parameters: Dict[str, Any]) -> str: + """Assemble the final research prompt from prompt + optional context/documentList.""" + base_prompt = (parameters.get("prompt") or "").strip() + context_val = parameters.get("context") + doc_list = parameters.get("documentList") + + parts: list[str] = [] + + # Prepend context string if provided + if context_val and isinstance(context_val, str) and context_val.strip(): + parts.append(f"Kontext:\n{context_val.strip()}") + + # Extract text from documentList items if provided + if doc_list: + docs: list = [] + if isinstance(doc_list, dict): + docs = doc_list.get("documents", []) or doc_list.get("items", []) + elif isinstance(doc_list, list): + docs = doc_list + doc_texts = [] + for d in docs: + if isinstance(d, dict): + text = d.get("documentData") or d.get("text") or d.get("content") or "" + if text and isinstance(text, str): + doc_texts.append(text.strip()) + if doc_texts: + parts.append("Dokumente:\n" + "\n---\n".join(doc_texts)) + + parts.append(base_prompt) + return "\n\n".join(p for p in parts if p) + + async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult: operationId = None try: - prompt = parameters.get("prompt") + prompt = _build_research_prompt(parameters) if not prompt: return ActionResult.isFailure(error="Research prompt is required") From f96325f804db08a2fd80018834e5d6533d1589dd Mon Sep 17 00:00:00 2001 From: Ida Date: Sun, 3 May 2026 15:50:11 +0200 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20unify=20workflow=20context=20pick?= =?UTF-8?q?er=20=E2=80=94=20contextBuilder=20multi-select,=20lift=20type-b?= =?UTF-8?q?locking,=20user-language=20labels,=20backend=20serialization,?= =?UTF-8?q?=20fix=20circular=20ref=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaceFeatureGraphicalEditor.py | 19 ++++++-- .../graphicalEditor/nodeDefinitions/ai.py | 46 +++++++++---------- .../graphicalEditor/nodeDefinitions/email.py | 4 +- .../graphicalEditor/nodeDefinitions/file.py | 4 +- .../nodeDefinitions/trustee.py | 4 +- modules/features/graphicalEditor/portTypes.py | 4 +- modules/workflows/automation2/graphUtils.py | 6 +++ modules/workflows/methods/methodAi/_common.py | 21 +++++++++ .../methods/methodAi/actions/generateCode.py | 10 ++-- .../methodAi/actions/generateDocument.py | 10 ++-- .../methods/methodAi/actions/process.py | 19 +++----- .../methods/methodAi/actions/webResearch.py | 8 ++-- .../methods/methodFile/actions/create.py | 7 ++- .../composeAndDraftEmailWithContext.py | 3 +- 14 files changed, 99 insertions(+), 66 deletions(-) diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py index 3b665981..b0291600 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py @@ -12,17 +12,30 @@ import uuid from typing import Dict, Any, List, Optional -def _make_json_serializable(obj: Any) -> Any: +_INTERNAL_SKIP_KEYS = frozenset({"_context", "_orderedNodes"}) + + +def _make_json_serializable(obj: Any, _depth: int = 0) -> Any: """ Recursively convert bytes to base64 strings so structures can be JSON-serialized for storage in JSONB columns. + + Internal runtime keys (_context, _orderedNodes) are skipped — they hold live + Python objects (including back-references to nodeOutputs) and must never be + stored. A depth guard prevents runaway recursion on unexpected circular refs. """ + if _depth > 50: + return None if isinstance(obj, bytes): return base64.b64encode(obj).decode("ascii") if isinstance(obj, dict): - return {k: _make_json_serializable(v) for k, v in obj.items()} + return { + k: _make_json_serializable(v, _depth + 1) + for k, v in obj.items() + if k not in _INTERNAL_SKIP_KEYS + } if isinstance(obj, list): - return [_make_json_serializable(v) for v in obj] + return [_make_json_serializable(v, _depth + 1) for v in obj] return obj from modules.datamodels.datamodelUam import User diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py index 857b1516..c575f39c 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py @@ -24,10 +24,10 @@ AI_NODES = [ {"name": "resultType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]}, "description": t("Ausgabeformat"), "default": "txt"}, - {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", - "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Kontextdaten fuer den Prompt (Upstream-Output binden)"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", + "description": t("Dokumente aus vorherigen Schritten"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, {"name": "documentTheme", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]}, "description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"}, @@ -37,7 +37,7 @@ AI_NODES = [ "inputs": 1, "outputs": 1, "inputPorts": {0: {"accepts": [ - "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult", + "FormPayload", "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult", ]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True}, @@ -52,14 +52,14 @@ AI_NODES = [ "parameters": [ {"name": "prompt", "type": "str", "required": True, "frontendType": "textarea", "description": t("Recherche-Anfrage")}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, - {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", - "description": t("Optionale Dokumentenliste aus Upstream-Node (Text wird dem Prompt hinzugefügt)"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", + "description": t("Dokumente aus vorherigen Schritten"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -72,7 +72,7 @@ AI_NODES = [ "description": t("Dokumentinhalt zusammenfassen"), "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", - "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, + "description": t("Dokumente aus vorherigen Schritten")}, {"name": "summaryLength", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["brief", "medium", "detailed"]}, "description": t("Kurz, mittel oder ausführlich"), "default": "medium"}, @@ -92,7 +92,7 @@ AI_NODES = [ "description": t("Dokument in Zielsprache übersetzen"), "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", - "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, + "description": t("Dokumente aus vorherigen Schritten")}, {"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text", "description": t("Zielsprache (z.B. de, en, French)")}, ] + _AI_COMMON_PARAMS, @@ -111,7 +111,7 @@ AI_NODES = [ "description": t("Dokument in anderes Format konvertieren"), "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", - "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, + "description": t("Dokumente aus vorherigen Schritten")}, {"name": "targetFormat", "type": "str", "required": True, "frontendType": "select", "frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]}, "description": t("Zielformat")}, @@ -132,14 +132,14 @@ AI_NODES = [ "parameters": [ {"name": "prompt", "type": "str", "required": True, "frontendType": "textarea", "description": t("Generierungs-Prompt")}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, - {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", - "description": t("Optionale Dokumentenliste als Vorlage/Referenz"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", + "description": t("Dokumente aus vorherigen Schritten"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -156,14 +156,14 @@ AI_NODES = [ {"name": "resultType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]}, "description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, - {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", - "description": t("Optionale Dokumentenliste als Referenz"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", + "description": t("Dokumente aus vorherigen Schritten"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True}, "_method": "ai", diff --git a/modules/features/graphicalEditor/nodeDefinitions/email.py b/modules/features/graphicalEditor/nodeDefinitions/email.py index 698efa94..8f316605 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/email.py +++ b/modules/features/graphicalEditor/nodeDefinitions/email.py @@ -62,8 +62,8 @@ EMAIL_NODES = [ {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("E-Mail-Konto")}, - {"name": "context", "type": "str", "required": False, "frontendType": "templateTextarea", - "description": t("Kontext / Brief-Beschreibung für die KI-Komposition"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea", + "description": t("Daten aus vorherigen Schritten (oder direkte Beschreibung)"), "default": ""}, {"name": "to", "type": "str", "required": False, "frontendType": "text", "description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""}, {"name": "documentList", "type": "str", "required": False, "frontendType": "hidden", diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index 9fbd261d..9795903d 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -23,8 +23,8 @@ FILE_NODES = [ {"name": "language", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["de", "en", "fr"]}, "description": t("Sprache"), "default": "de"}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Inhalt aus Upstream-Node (binden via DataRef oder Wire)"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/features/graphicalEditor/nodeDefinitions/trustee.py index 7392b4d2..3adc9d3f 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/trustee.py +++ b/modules/features/graphicalEditor/nodeDefinitions/trustee.py @@ -77,7 +77,7 @@ TRUSTEE_NODES = [ # 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("Dokumentenliste — gebunden via DataRef.")}, + "description": t("Dokumente aus vorherigen Schritten")}, dict(_TRUSTEE_INSTANCE_PARAM), ], "inputs": 1, @@ -95,7 +95,7 @@ TRUSTEE_NODES = [ "description": t("Trustee-Positionen in Buchhaltungssystem übertragen."), "parameters": [ {"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef", - "description": t("Verarbeitete Dokumentenliste — gebunden via DataRef.")}, + "description": t("Dokumente aus vorherigen Schritten")}, dict(_TRUSTEE_INSTANCE_PARAM), ], "inputs": 1, diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index 246d4791..56de0b26 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -34,6 +34,8 @@ class PortField(BaseModel): # 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 class PortSchema(BaseModel): @@ -153,7 +155,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = { ]), "DocumentList": PortSchema(name="DocumentList", fields=[ PortField(name="documents", type="List[Document]", - description="Dokumentenliste"), + 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, diff --git a/modules/workflows/automation2/graphUtils.py b/modules/workflows/automation2/graphUtils.py index 1d2aeb13..72b6b9f4 100644 --- a/modules/workflows/automation2/graphUtils.py +++ b/modules/workflows/automation2/graphUtils.py @@ -398,5 +398,11 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any: return str(data) if data is not None else m.group(0) return re.sub(r"\{\{\s*([^}]+)\s*\}\}", repl, value) if isinstance(value, list): + # contextBuilder: list where every item is a `{"type":"ref",...}` envelope. + # Resolve each ref and join the serialised parts into a single prompt string. + if value and all(isinstance(v, dict) and v.get("type") == "ref" for v in value): + from modules.workflows.methods.methodAi._common import serialize_context + parts = [serialize_context(resolveParameterReferences(v, nodeOutputs)) for v in value] + return "\n\n".join(p for p in parts if p) return [resolveParameterReferences(v, nodeOutputs) for v in value] return value diff --git a/modules/workflows/methods/methodAi/_common.py b/modules/workflows/methods/methodAi/_common.py index 9e77d431..d913f9e4 100644 --- a/modules/workflows/methods/methodAi/_common.py +++ b/modules/workflows/methods/methodAi/_common.py @@ -3,6 +3,27 @@ """Shared helpers for AI workflow actions.""" +import json +from typing import Any + + +def serialize_context(val: Any) -> str: + """Convert any context value to a readable string for use in AI prompts. + + - None / empty string → "" + - str → as-is + - dict / list → pretty-printed JSON + - anything else → str() + """ + if val is None or val == "" or val == []: + return "" + if isinstance(val, str): + return val.strip() + try: + return json.dumps(val, ensure_ascii=False, indent=2) + except Exception: + return str(val) + def applyCommonAiParams(parameters: dict, request) -> None: """Apply common AI parameters (requireNeutralization, allowedModels) from node to request.""" diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py index 24237289..ee375d89 100644 --- a/modules/workflows/methods/methodAi/actions/generateCode.py +++ b/modules/workflows/methods/methodAi/actions/generateCode.py @@ -14,12 +14,10 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil logger = logging.getLogger(__name__) async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult: - base_prompt = parameters.get("prompt") or "" - context_val = parameters.get("context") - if context_val and isinstance(context_val, str) and context_val.strip(): - prompt = f"Kontext:\n{context_val.strip()}\n\n{base_prompt.strip()}" - else: - prompt = base_prompt + from modules.workflows.methods.methodAi._common import serialize_context + base_prompt = (parameters.get("prompt") or "").strip() + context_val = serialize_context(parameters.get("context")) + prompt = f"Kontext:\n{context_val}\n\n{base_prompt}" if context_val else base_prompt if not prompt.strip(): return ActionResult.isFailure(error="prompt is required") diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 1d4a4b66..a8fcaf0f 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -14,12 +14,10 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil logger = logging.getLogger(__name__) async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: - base_prompt = parameters.get("prompt") or "" - context_val = parameters.get("context") - if context_val and isinstance(context_val, str) and context_val.strip(): - prompt = f"Kontext:\n{context_val.strip()}\n\n{base_prompt.strip()}" - else: - prompt = base_prompt + from modules.workflows.methods.methodAi._common import serialize_context + base_prompt = (parameters.get("prompt") or "").strip() + context_val = serialize_context(parameters.get("context")) + prompt = f"Kontext:\n{context_val}\n\n{base_prompt}" if context_val else base_prompt if not prompt.strip(): return ActionResult.isFailure(error="prompt is required") diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 2af480e7..fee57c2e 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -220,17 +220,12 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult: mimeMap = {"txt": "text/plain", "json": "application/json", "html": "text/html", "md": "text/markdown", "csv": "text/csv", "xml": "application/xml"} output_mime_type = mimeMap.get(normalized_result_type, "text/plain") if normalized_result_type else "text/plain" - # Normalize context: workflow refs may resolve to dict/list instead of str - paramContext = parameters.get("context") - if paramContext is not None and not isinstance(paramContext, str): - try: - paramContext = json.dumps(paramContext, ensure_ascii=False, default=str) - parameters["context"] = paramContext - logger.info(f"ai.process: Serialized non-string context ({type(parameters.get('context')).__name__}) to JSON ({len(paramContext)} chars)") - except Exception as e: - logger.warning(f"ai.process: Failed to serialize context: {e}") - paramContext = str(paramContext) - parameters["context"] = paramContext + # Normalize context: serialize any non-string value (dict/list/int/…) to text + from modules.workflows.methods.methodAi._common import serialize_context + paramContext = serialize_context(parameters.get("context")) + parameters["context"] = paramContext + if paramContext: + logger.info(f"ai.process: context serialized ({len(paramContext)} chars)") # Phase 7.3: Pass documentList and/or contentParts to AI service contentParts: Optional[List[ContentPart]] = inline_content_parts @@ -257,7 +252,7 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult: self.services.chat.progressLogUpdate(operationId, 0.6, "Calling AI (simple mode)") context_parts = [] - paramContext = parameters.get("context") + paramContext = parameters.get("context") # already serialized above if paramContext and isinstance(paramContext, str) and paramContext.strip(): context_parts.append(paramContext.strip()) if documentList and len(documentList.references) > 0: diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py index 2cf388b9..e32f8e65 100644 --- a/modules/workflows/methods/methodAi/actions/webResearch.py +++ b/modules/workflows/methods/methodAi/actions/webResearch.py @@ -15,15 +15,15 @@ logger = logging.getLogger(__name__) def _build_research_prompt(parameters: Dict[str, Any]) -> str: """Assemble the final research prompt from prompt + optional context/documentList.""" + from modules.workflows.methods.methodAi._common import serialize_context base_prompt = (parameters.get("prompt") or "").strip() - context_val = parameters.get("context") + context_val = serialize_context(parameters.get("context")) doc_list = parameters.get("documentList") parts: list[str] = [] - # Prepend context string if provided - if context_val and isinstance(context_val, str) and context_val.strip(): - parts.append(f"Kontext:\n{context_val.strip()}") + if context_val: + parts.append(f"Kontext:\n{context_val}") # Extract text from documentList items if provided if doc_list: diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index 5feb6287..96804b10 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -74,10 +74,9 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult: Create a file from context (text/markdown from upstream AI node). Uses GenerationService.renderReport to produce docx, pdf, txt, md, html, xlsx, etc. """ - context = parameters.get("context", "") or parameters.get("text", "") or "" - if not isinstance(context, str): - context = str(context) if context else "" - context = context.strip() + from modules.workflows.methods.methodAi._common import serialize_context + raw_context = parameters.get("context", "") or parameters.get("text", "") or "" + context = serialize_context(raw_context) if not context: return ActionResult.isFailure(error="context is required (connect an AI node or provide text)") diff --git a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py index 43c4dc41..a191e84b 100644 --- a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py +++ b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py @@ -14,7 +14,8 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A try: connectionReference = parameters.get("connectionReference") to = parameters.get("to") or [] # Optional for drafts - can save draft without recipients - context = parameters.get("context") + from modules.workflows.methods.methodAi._common import serialize_context + context = serialize_context(parameters.get("context")) or None documentList = parameters.get("documentList") or [] replySourceDocuments = parameters.get("replySourceDocuments") or [] # Original email(s) for reply attachment # ``attachments`` (added in 2026-04 for the PWG pilot) is a list of From e6ca6a9d8e4f23fcd94b8518d0d626b778eaccc1 Mon Sep 17 00:00:00 2001 From: Ida Date: Sun, 3 May 2026 18:01:10 +0200 Subject: [PATCH 03/14] ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes --- modules/aicore/aicorePluginAnthropic.py | 32 +++- modules/connectors/connectorDbPostgre.py | 7 +- .../graphicalEditor/nodeDefinitions/ai.py | 8 + .../graphicalEditor/nodeDefinitions/file.py | 2 +- modules/features/graphicalEditor/portTypes.py | 9 +- modules/interfaces/interfaceDbManagement.py | 169 ++++++++++++++++-- modules/interfaces/interfaceRbac.py | 10 ++ .../services/serviceAi/subAiCallLooping.py | 31 +++- .../serviceAi/subStructureGeneration.py | 23 ++- .../services/serviceChat/mainServiceChat.py | 6 +- .../renderers/rendererCodeCsv.py | 12 +- .../renderers/rendererCodeJson.py | 12 +- .../renderers/rendererCodeXml.py | 11 +- .../renderers/rendererCsv.py | 11 +- .../renderers/rendererImage.py | 11 +- .../renderers/rendererJson.py | 11 +- .../renderers/rendererMarkdown.py | 11 +- .../renderers/rendererText.py | 64 ++++++- .../executors/actionNodeExecutor.py | 30 +++- modules/workflows/automation2/graphUtils.py | 90 ++++++++-- modules/workflows/methods/methodAi/_common.py | 3 + .../methodAi/actions/generateDocument.py | 27 ++- .../workflows/methods/methodAi/methodAi.py | 31 +++- .../methods/methodFile/actions/create.py | 19 +- 24 files changed, 564 insertions(+), 76 deletions(-) diff --git a/modules/aicore/aicorePluginAnthropic.py b/modules/aicore/aicorePluginAnthropic.py index 3b5eccda..c6f21423 100644 --- a/modules/aicore/aicorePluginAnthropic.py +++ b/modules/aicore/aicorePluginAnthropic.py @@ -351,6 +351,7 @@ class AiAnthropic(BaseConnectorAi): # Parse response anthropicResponse = response.json() + stop_reason = anthropicResponse.get("stop_reason") # Extract content and tool_use blocks from response content = "" @@ -374,9 +375,25 @@ class AiAnthropic(BaseConnectorAi): if not content and not toolCalls: logger.warning(f"Anthropic API returned empty content. Full response: {anthropicResponse}") - content = "[Anthropic API returned empty response]" + err = ( + "Anthropic refused the request (content policy) — try another model or adjust the prompt." + if stop_reason == "refusal" + else f"Anthropic returned no assistant text (stop_reason={stop_reason or 'unknown'})." + ) + return AiModelResponse( + content="", + success=False, + error=err, + modelId=model.name, + metadata={ + "response_id": anthropicResponse.get("id", ""), + "stop_reason": stop_reason, + }, + ) metadata = {"response_id": anthropicResponse.get("id", "")} + if stop_reason: + metadata["stop_reason"] = stop_reason if toolCalls: metadata["toolCalls"] = toolCalls @@ -492,6 +509,19 @@ class AiAnthropic(BaseConnectorAi): f"Anthropic stream returned empty response: model={model.name}, " f"stopReason={stopReason}" ) + err = ( + "Anthropic refused the request (content policy) — try another model or adjust the prompt." + if stopReason == "refusal" + else f"Anthropic returned no assistant text (stop_reason={stopReason or 'unknown'})." + ) + yield AiModelResponse( + content="", + success=False, + error=err, + modelId=model.name, + metadata={"stopReason": stopReason} if stopReason else {}, + ) + return metadata: Dict[str, Any] = {} if stopReason: diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 9e8e9e18..9f16b1f4 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -834,7 +834,10 @@ class DatabaseConnector: createdTs = record.get("sysCreatedAt") if createdTs is None or createdTs == 0 or createdTs == 0.0: record["sysCreatedAt"] = currentTime - if effective_user_id: + # Do not wipe caller-provided sysCreatedBy (e.g. FileItem from createFile with + # real user). ContextVar can be "system" for the DB pool while the business + # user is set on the record from model_dump(). + if effective_user_id and not record.get("sysCreatedBy"): record["sysCreatedBy"] = effective_user_id elif not record.get("sysCreatedBy"): if effective_user_id: @@ -1531,7 +1534,7 @@ class DatabaseConnector: createdTs = rec.get("sysCreatedAt") if createdTs is None or createdTs == 0 or createdTs == 0.0: rec["sysCreatedAt"] = currentTime - if effectiveUserId: + if effectiveUserId and not rec.get("sysCreatedBy"): rec["sysCreatedBy"] = effectiveUserId elif not rec.get("sysCreatedBy") and effectiveUserId: rec["sysCreatedBy"] = effectiveUserId diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py index c575f39c..4e29709e 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py @@ -132,6 +132,14 @@ AI_NODES = [ "parameters": [ {"name": "prompt", "type": "str", "required": True, "frontendType": "textarea", "description": t("Generierungs-Prompt")}, + {"name": "outputFormat", "type": "str", "required": False, "frontendType": "select", + "frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]}, + "description": t("Ausgabeformat"), "default": "docx"}, + {"name": "title", "type": "str", "required": False, "frontendType": "text", + "description": t("Dokumenttitel (Metadaten / Dateiname)"), "default": ""}, + {"name": "documentType", "type": "str", "required": False, "frontendType": "select", + "frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]}, + "description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"}, {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", "description": t("Daten aus vorherigen Schritten"), "default": ""}, {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index 9795903d..3b5ebfd4 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -28,7 +28,7 @@ FILE_NODES = [ ], "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit"]}}, + "inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload"]}}, "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False}, "_method": "file", diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index 56de0b26..bd092745 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -221,9 +221,9 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = { PortField(name="prompt", type="str", description="Prompt"), PortField(name="response", type="str", - description="Antworttext"), + description="Antworttext", recommended=True), PortField(name="responseData", type="Dict", required=False, - description="Strukturierte Antwort"), + description="Strukturierte Antwort (nur bei JSON-Ausgabe)"), PortField(name="context", type="str", description="Kontext"), PortField(name="documents", type="List[Document]", @@ -660,8 +660,11 @@ def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]: if not schema or schemaName == "Transit": return result + # Only default **required** fields. Optional fields stay absent so DataRefs / context + # resolution never pick a synthetic `{}` or `[]` (e.g. AiResult.responseData when the + # model returned plain text only). for field in schema.fields: - if field.name not in result: + if field.name not in result and field.required: result[field.name] = _defaultForType(field.type) return result diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 120aecce..9c3000e4 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -114,7 +114,15 @@ class ComponentObjects: # Update database context self.db.updateContext(self.userId) - + + def _effective_user_id(self) -> Optional[str]: + """User id for audit + FileData writes; singleton hub may unset userId but keep currentUser.""" + if self.userId: + return self.userId + if self.currentUser is not None: + return getattr(self.currentUser, "id", None) + return None + def __del__(self): """Cleanup method to close database connection.""" if hasattr(self, 'db') and self.db is not None: @@ -1379,10 +1387,31 @@ class ComponentObjects: fileSize=fileSize, fileHash=fileHash, ) - + # Ensure audit user is always stored: workflow/singleton contexts sometimes leave + # the connector without _current_user_id, so _saveRecord skips sysCreatedBy → + # getFile/createFileData RBAC then breaks (None != self.userId). + uid = self._effective_user_id() + if uid: + fileItem = fileItem.model_copy(update={"sysCreatedBy": str(uid)}) + # Store in database self.db.recordCreate(FileItem, fileItem) - + verify = self.db.getRecordset(FileItem, recordFilter={"id": fileItem.id}) + verify_creator = (verify[0].get("sysCreatedBy") if verify else None) + logger.info( + "createFile: id=%s name=%s scope=%s model_sysCreatedBy=%r db_sysCreatedBy=%r mandateId=%r featureInstanceId=%r " + "verify_rows=%s db=%s", + fileItem.id, + uniqueName, + fileItem.scope, + getattr(fileItem, "sysCreatedBy", None), + verify_creator, + mandateId or None, + featureInstanceId if featureInstanceId else None, + len(verify) if verify else 0, + getattr(self.db, "dbDatabase", "?"), + ) + return fileItem def _isFileOwner(self, file) -> bool: @@ -1579,14 +1608,134 @@ class ComponentObjects: return success # FileData methods - data operations - + + def _getFileItemForDataWrite(self, fileId: str) -> Optional[FileItem]: + """Resolve FileItem for storing FileData: RBAC-aware getFile, then same-user row fallback. + + createFile() can insert a row that getFile() still hides (e.g. scope NULL vs GROUP rules, + or connector / context edge cases). The creator must still be allowed to attach blob data. + """ + logger.info( + "[FileData] resolve start fileId=%s iface_userId=%r effective_uid=%r mandateId=%r featureInstanceId=%r db=%s", + fileId, + self.userId, + self._effective_user_id(), + self.mandateId, + self.featureInstanceId, + getattr(self.db, "dbDatabase", "?"), + ) + file = self.getFile(fileId) + if file: + logger.info("[FileData] getFile OK fileId=%s", fileId) + return file + uid = self._effective_user_id() + if not uid: + logger.error( + "[FileData] FAIL no user id fileId=%s userId=%r hasCurrentUser=%s", + fileId, + self.userId, + self.currentUser is not None, + ) + return None + uid_s = str(uid) + rows = self.db.getRecordset(FileItem, recordFilter={"id": fileId}) + if not rows: + logger.error( + "[FileData] FAIL no FileItem row fileId=%s (createFile committed to same db? db=%s)", + fileId, + getattr(self.db, "dbDatabase", "?"), + ) + return None + row = dict(rows[0]) + creator = row.get("sysCreatedBy") + creator_s = str(creator) if creator is not None else None + if creator_s != uid_s: + if not creator_s: + try: + self.db.recordModify(FileItem, fileId, {"sysCreatedBy": uid_s}) + row["sysCreatedBy"] = uid_s + logger.warning( + "[FileData] patched NULL sysCreatedBy fileId=%s -> %s", + fileId, + uid_s, + ) + except Exception as e: + logger.error( + "[FileData] FAIL patch sysCreatedBy fileId=%s: %s", + fileId, + e, + exc_info=True, + ) + return None + else: + # _saveRecord used to overwrite explicit creators with contextvar "system" + if creator_s == "system": + try: + self.db.recordModify(FileItem, fileId, {"sysCreatedBy": uid_s}) + row["sysCreatedBy"] = uid_s + logger.warning( + "[FileData] patched sysCreatedBy system→user fileId=%s -> %s", + fileId, + uid_s, + ) + except Exception as e: + logger.error( + "[FileData] FAIL patch system sysCreatedBy fileId=%s: %s", + fileId, + e, + exc_info=True, + ) + return None + else: + logger.error( + "[FileData] FAIL creator mismatch fileId=%s row.sysCreatedBy=%r (%s) effective_uid=%r (%s) scope=%r", + fileId, + creator, + type(creator).__name__, + uid, + type(uid).__name__, + row.get("scope"), + ) + return None + logger.info( + "[FileData] RBAC miss, owner fallback OK fileId=%s scope=%r sysCreatedBy=%r", + fileId, + row.get("scope"), + row.get("sysCreatedBy"), + ) + try: + if row.get("sysCreatedAt") is None or row.get("sysCreatedAt") in (0, 0.0): + row["sysCreatedAt"] = getUtcTimestamp() + if row.get("scope") is None: + row["scope"] = "personal" + if row.get("neutralize") is None: + row["neutralize"] = False + return FileItem(**row) + except Exception as e: + logger.error( + "[FileData] FAIL FileItem(**row) fileId=%s keys=%s err=%s", + fileId, + list(row.keys()), + e, + exc_info=True, + ) + return None + def createFileData(self, fileId: str, data: bytes) -> bool: """Stores the binary data of a file in the database.""" try: + logger.info( + "[FileData] createFileData enter fileId=%s bytes=%s", + fileId, + len(data) if data is not None else 0, + ) # Check file access - file = self.getFile(fileId) + file = self._getFileItemForDataWrite(fileId) if not file: - logger.error(f"File with ID {fileId} not found when storing data") + logger.error( + "[FileData] FAIL _getFileItemForDataWrite returned None fileId=%s", + fileId, + ) return False # Determine if this is a text-based format @@ -1630,13 +1779,11 @@ class ComponentObjects: } self.db.recordCreate(FileData, fileDataObj) - - # Clear cache to ensure fresh data - - logger.debug(f"Successfully stored data for file {fileId} (base64Encoded: {base64Encoded})") + + logger.info("[FileData] recordCreate OK fileId=%s base64Encoded=%s", fileId, base64Encoded) return True except Exception as e: - logger.error(f"Error storing data for file {fileId}: {str(e)}") + logger.error("Error storing data for file %s: %s", fileId, e, exc_info=True) return False def getFileData(self, fileId: str) -> Optional[bytes]: diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 42a32b82..e41485e0 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -747,6 +747,7 @@ def buildFilesScopeWhereClause( Only own files: sysCreatedBy = currentUser WITH instance context (Instanz-Seiten): + - scope = 'personal' AND sysCreatedBy = me (creator's personal files; e.g. workflow outputs) - sysCreatedBy = me AND featureInstanceId = X (own personal files of this instance) - scope = 'featureInstance' AND featureInstanceId = X - scope = 'mandate' AND mandateId = M (M = mandate of the instance) @@ -780,6 +781,15 @@ def buildFilesScopeWhereClause( scopeParts: List[str] = [] scopeValues: List = [] + # Personal files created by this user must remain visible even when the request + # carries mandate/instance context (GROUP reads use this clause). Otherwise + # createFile → createFileData → getFile fails and workflow outputs vanish from /files. + # Also treat scope IS NULL as legacy/personal for the owner (column default not applied). + scopeParts.append( + '(("scope" = \'personal\' OR "scope" IS NULL) AND "sysCreatedBy" = %s)' + ) + scopeValues.append(currentUser.id) + if featureInstanceId: # 1) Own personal files of this specific instance scopeParts.append('("sysCreatedBy" = %s AND "featureInstanceId" = %s)') diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index adfc4d8a..cc3a014b 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -142,6 +142,8 @@ class AiCallLooper: MAX_MERGE_FAILS = 3 mergeFailCount = 0 # Global counter for merge failures across entire loop lastValidCompletePart = None # Store last successfully parsed completePart for fallback + MAX_CONSECUTIVE_EMPTY_RESPONSES = 3 + consecutive_empty_responses = 0 # Get parent operation ID for iteration operations (parentId should be operationId, not log entry ID) parentOperationId = operationId # Use the parent's operationId directly @@ -284,8 +286,26 @@ class AiCallLooper: break if not result or not result.strip(): - logger.warning(f"Iteration {iteration}: Empty response, stopping") - break + consecutive_empty_responses += 1 + logger.warning( + "Iteration %s: Empty AI response (consecutive %s/%s) modelName=%s errorCount=%s", + iteration, + consecutive_empty_responses, + MAX_CONSECUTIVE_EMPTY_RESPONSES, + getattr(response, "modelName", None), + getattr(response, "errorCount", None), + ) + if iterationOperationId: + self.services.chat.progressLogFinish(iterationOperationId, False) + if consecutive_empty_responses >= MAX_CONSECUTIVE_EMPTY_RESPONSES: + logger.error( + "Stopping loop: %s consecutive empty responses from model", + consecutive_empty_responses, + ) + break + continue + + consecutive_empty_responses = 0 # Check if this is a text response (not document generation) # Text responses don't need JSON parsing - return immediately after first successful response @@ -535,7 +555,12 @@ class AiCallLooper: # This code path should never be reached because all registered use cases # return early when JSON is complete. This would only execute for use cases that # require section extraction, but no such use cases are currently registered. - logger.error(f"Unexpected code path: reached end of loop without return for use case '{useCaseId}'") + logger.error( + "End of callAiWithLooping without success for use case %r (iterations=%s, lastResultLen=%s)", + useCaseId, + iteration, + len(result) if isinstance(result, str) else 0, + ) return result if result else "" def _isJsonStringIncomplete(self, jsonString: str) -> bool: diff --git a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py index 3d531756..16cbb786 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py +++ b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py @@ -90,8 +90,7 @@ class StructureGenerator: ) try: - # Baue Chapter-Struktur-Prompt mit Content-Index - structurePrompt = self._buildChapterStructurePrompt( + structurePrompt, templateStructure = self._buildChapterStructurePrompt( userPrompt=userPrompt, contentParts=contentParts, outputFormat=outputFormat @@ -108,12 +107,6 @@ class StructureGenerator: resultFormat="json" ) - structurePrompt, templateStructure = self._buildChapterStructurePrompt( - userPrompt=userPrompt, - contentParts=contentParts, - outputFormat=outputFormat - ) - # Create prompt builder for continuation support async def buildChapterStructurePromptWithContinuation( continuationContext: Any, @@ -196,6 +189,13 @@ CRITICAL: contentParts=None # Do not pass ContentParts - only metadata needed, not content extraction ) + if not isinstance(aiResponseJson, str) or not aiResponseJson.strip(): + raise ValueError( + "Structure generation returned no JSON text from the model (empty response after retries). " + "Check the AI provider, allowed models, billing, and debug artifact " + "'chapter_structure_generation_response'." + ) + # Parse the complete JSON response (looping system already handles completion) extractedJson = self.services.utils.jsonExtractString(aiResponseJson) parsedJson, parseError, cleanedJson = self.services.utils.jsonTryParse(extractedJson) @@ -215,7 +215,12 @@ CRITICAL: raise ValueError(f"Failed to parse JSON structure after repair: {str(parseError)}") else: logger.error(f"Failed to repair JSON. Parse error: {str(parseError)}") - logger.error(f"Cleaned JSON preview (first 500 chars): {cleanedJson[:500]}") + raw_preview = (extractedJson or "")[:500] + logger.error( + "Raw extract preview (first 500 chars): %r", + raw_preview, + ) + logger.error(f"Cleaned JSON preview (first 500 chars): {cleanedJson[:500]!r}") raise ValueError(f"Failed to parse JSON structure: {str(parseError)}") else: structure = parsedJson diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 0e69344a..fcf9be2f 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -23,7 +23,11 @@ class ChatService: 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) + self.interfaceDbComponent = getComponentInterface( + context.user, + mandateId=context.mandate_id, + featureInstanceId=context.feature_instance_id, + ) self.interfaceDbChat = getChatInterface( context.user, mandateId=context.mandate_id, diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py index cb6d77ca..e430c302 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py @@ -79,7 +79,15 @@ class RendererCodeCsv(BaseCodeRenderer): return renderedDocs - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: + async def render( + self, + extractedContent: Dict[str, Any], + title: str, + userPrompt: str = None, + aiService=None, + *, + style: Dict[str, Any] = None, + ) -> List[RenderedDocument]: """ Render method for document generation compatibility. Delegates to document renderer if needed, or handles code files directly. @@ -94,7 +102,7 @@ class RendererCodeCsv(BaseCodeRenderer): # Document generation path - delegate to document renderer from .rendererCsv import RendererCsv documentRenderer = RendererCsv(self.services) - return await documentRenderer.render(extractedContent, title, userPrompt, aiService) + return await documentRenderer.render(extractedContent, title, userPrompt, aiService, style=style) def _validateAndFixCsv(self, content: str) -> str: """Validate CSV structure and fix common issues.""" diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py index dff849ef..143be000 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py @@ -91,7 +91,15 @@ class RendererCodeJson(BaseCodeRenderer): return renderedDocs - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: + async def render( + self, + extractedContent: Dict[str, Any], + title: str, + userPrompt: str = None, + aiService=None, + *, + style: Dict[str, Any] = None, + ) -> List[RenderedDocument]: """ Render method for document generation compatibility. Delegates to document renderer if needed, or handles code files directly. @@ -107,7 +115,7 @@ class RendererCodeJson(BaseCodeRenderer): # Import here to avoid circular dependency from .rendererJson import RendererJson documentRenderer = RendererJson(self.services) - return await documentRenderer.render(extractedContent, title, userPrompt, aiService) + return await documentRenderer.render(extractedContent, title, userPrompt, aiService, style=style) def _extractJsonStatistics(self, parsed: Any) -> Dict[str, Any]: """Extract JSON statistics for validation (object count, array count, key count).""" diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py index 6967f746..f4952679 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py @@ -78,11 +78,20 @@ class RendererCodeXml(BaseCodeRenderer): return renderedDocs - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: + async def render( + self, + extractedContent: Dict[str, Any], + title: str, + userPrompt: str = None, + aiService=None, + *, + style: Dict[str, Any] = None, + ) -> List[RenderedDocument]: """ Render method for document generation compatibility. For XML, we only support code generation (no document renderer exists yet). """ + _ = style # Check if this is code generation (has files array) if "files" in extractedContent: # Code generation path - use renderCodeFiles diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py index f5ee252b..a8b2c346 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py @@ -39,8 +39,17 @@ class RendererCsv(BaseRenderer): """ return ["table", "code_block"] - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: + async def render( + self, + extractedContent: Dict[str, Any], + title: str, + userPrompt: str = None, + aiService=None, + *, + style: Dict[str, Any] = None, + ) -> List[RenderedDocument]: """Render extracted JSON content to CSV format. Produces one CSV file per table section.""" + _ = style try: # Validate JSON structure if not self._validateJsonStructure(extractedContent): diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py index 8141b798..58c0d04f 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py @@ -43,8 +43,17 @@ class RendererImage(BaseRenderer): """ return ["image"] - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: + async def render( + self, + extractedContent: Dict[str, Any], + title: str, + userPrompt: str = None, + aiService=None, + *, + style: Dict[str, Any] = None, + ) -> List[RenderedDocument]: """Render extracted JSON content to image format using AI image generation.""" + _ = style try: # Generate AI image from content imageContent = await self._generateAiImage(extractedContent, title, userPrompt, aiService) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py index 470d4543..bc6b6a85 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py @@ -42,8 +42,17 @@ class RendererJson(BaseRenderer): # Return all types except image return [st for st in supportedSectionTypes if st != "image"] - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: + async def render( + self, + extractedContent: Dict[str, Any], + title: str, + userPrompt: str = None, + aiService=None, + *, + style: Dict[str, Any] = None, + ) -> List[RenderedDocument]: """Render extracted JSON content to JSON format.""" + _ = style try: # The extracted content should already be JSON from the AI # Just validate and format it diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py index 552266e9..1113f1a2 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py @@ -40,8 +40,17 @@ class RendererMarkdown(BaseRenderer): from modules.datamodels.datamodelJson import supportedSectionTypes return [st for st in supportedSectionTypes if st != "image"] - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: + async def render( + self, + extractedContent: Dict[str, Any], + title: str, + userPrompt: str = None, + aiService=None, + *, + style: Dict[str, Any] = None, + ) -> List[RenderedDocument]: """Render extracted JSON content to Markdown format.""" + _ = style try: # Generate markdown from JSON structure markdownContent = self._generateMarkdownFromJson(extractedContent, title) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py index 94400df9..596feeeb 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py @@ -8,7 +8,7 @@ import re from .documentRendererBaseTemplate import BaseRenderer from modules.datamodels.datamodelDocument import RenderedDocument -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Union class RendererText(BaseRenderer): """Renders content to plain text format with format-specific extraction.""" @@ -76,8 +76,17 @@ class RendererText(BaseRenderer): # Text renderer accepts all types except images return [st for st in supportedSectionTypes if st != "image"] - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: + async def render( + self, + extractedContent: Dict[str, Any], + title: str, + userPrompt: str = None, + aiService=None, + *, + style: Dict[str, Any] = None, + ) -> List[RenderedDocument]: """Render extracted JSON content to plain text format.""" + _ = style # unified style from renderReport; plain text ignores formatting hints try: # Generate text from JSON structure textContent = self._generateTextFromJson(extractedContent, title) @@ -263,16 +272,16 @@ class RendererText(BaseRenderer): textParts = [] # Create table header - headerLine = " | ".join(str(header) for header in headers) + headerLine = " | ".join(self._tableCellToPlainText(h) for h in headers) textParts.append(headerLine) # Add separator line - separatorLine = " | ".join("-" * len(str(header)) for header in headers) + separatorLine = " | ".join("-" * len(self._tableCellToPlainText(h)) for h in headers) textParts.append(separatorLine) # Add data rows for row in rows: - rowLine = " | ".join(str(cellData) for cellData in row) + rowLine = " | ".join(self._tableCellToPlainText(cellData) for cellData in row) textParts.append(rowLine) return '\n'.join(textParts) @@ -299,6 +308,9 @@ class RendererText(BaseRenderer): textParts.append(f"- {self._stripMarkdownForPlainText(item)}") elif isinstance(item, dict) and "text" in item: textParts.append(f"- {self._stripMarkdownForPlainText(item['text'])}") + elif isinstance(item, list): + # markdownToDocumentJson: each item is List[InlineRun] + textParts.append(f"- {self._inlineRunsToPlainText(item)}") return '\n'.join(textParts) @@ -345,12 +357,54 @@ class RendererText(BaseRenderer): text = re.sub(r'`([^`]+)`', r'\1', text) return text.strip() + def _inlineRunsToPlainText(self, runs: Union[List[Any], Any]) -> str: + """Flatten InlineRun dicts (from markdownToDocumentJson) to a single string.""" + if runs is None: + return "" + if isinstance(runs, dict): + runs = [runs] + if not isinstance(runs, list): + return self._stripMarkdownForPlainText(str(runs)) + parts: List[str] = [] + for run in runs: + if not isinstance(run, dict): + parts.append(str(run)) + continue + t = run.get("type") or "text" + val = run.get("value", "") + if t == "text": + parts.append(str(val)) + elif t in ("bold", "italic", "code"): + parts.append(str(val)) + elif t == "link": + parts.append(str(val)) + elif t == "image": + parts.append(f"[{val}]") + else: + parts.append(str(val)) + return "".join(parts) + + def _tableCellToPlainText(self, cell: Any) -> str: + """Table header/cell: plain str, legacy dict, or List[InlineRun].""" + if cell is None: + return "" + if isinstance(cell, str): + return self._stripMarkdownForPlainText(cell) + if isinstance(cell, list): + return self._inlineRunsToPlainText(cell) + if isinstance(cell, dict) and "text" in cell: + return self._stripMarkdownForPlainText(str(cell["text"])) + return self._stripMarkdownForPlainText(str(cell)) + def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str: """Render a JSON paragraph to text. Strips markdown for plain text output.""" try: # Extract from nested content structure content = paragraphData.get("content", {}) if isinstance(content, dict): + runs = self._inlineRunsFromContent(content) + if runs: + return self._stripMarkdownForPlainText(self._inlineRunsToPlainText(runs)) text = content.get("text", "") elif isinstance(content, str): text = content diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index 163ed3b2..fe9fa13e 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -326,11 +326,25 @@ class ActionNodeExecutor: if isinstance(dumped, dict) and isinstance(rawData, bytes) and len(rawData) > 0: 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") - _mgmt = _getMgmtInterface(getRootUser(), mandateId=_mandateId, featureInstanceId=_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) _docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin" _mimeType = dumped.get("mimeType") or "application/octet-stream" _fileItem = _mgmt.createFile(_docName, _mimeType, rawData) @@ -345,6 +359,20 @@ class ActionNodeExecutor: dumped["_hasBinaryData"] = True docsList.append(dumped) + # Clean DocumentList shape for document nodes (match file.create: documents + count, no AiResult fields) + if outputSchema == "DocumentList" and nodeType in ("ai.generateDocument", "ai.convertDocument"): + if not result.success: + return _normalizeError( + RuntimeError(str(result.error or "document action failed")), + outputSchema, + ) + list_out: Dict[str, Any] = { + "documents": docsList, + "count": len(docsList), + } + _attachConnectionProvenance(list_out, resolvedParams, outputSchema, chatService, self.services) + return normalizeToSchema(list_out, outputSchema) + extractedContext = "" if result.documents: doc = result.documents[0] diff --git a/modules/workflows/automation2/graphUtils.py b/modules/workflows/automation2/graphUtils.py index 72b6b9f4..7ea3b4e8 100644 --- a/modules/workflows/automation2/graphUtils.py +++ b/modules/workflows/automation2/graphUtils.py @@ -7,6 +7,50 @@ from typing import Dict, List, Any, Tuple, Set, Optional logger = logging.getLogger(__name__) +def _ai_result_text_from_documents(d: Dict[str, Any]) -> Optional[str]: + """Extract plain-text body from AiResult-style ``documents[0].documentData``.""" + docs = d.get("documents") + if not isinstance(docs, list) or not docs: + return None + d0 = docs[0] + raw: Any = None + if isinstance(d0, dict): + raw = d0.get("documentData") + elif d0 is not None: + raw = getattr(d0, "documentData", None) + if raw is None: + return None + if isinstance(raw, bytes): + try: + t = raw.decode("utf-8").strip() + return t or None + except (UnicodeDecodeError, ValueError): + return None + if isinstance(raw, str): + s = raw.strip() + return s or None + return None + + +def _ref_coalesce_empty_ai_result_text(data: Any, path: List[Any], resolved: Any) -> Any: + """If a ref targets AiResult text fields but resolves empty/missing, fall back to documents. + + Needed when: optional ``responseData`` is absent (no synthetic ``{}``), ``response`` is + still empty but ``documents`` hold the model output, or legacy graphs bind responseData only. + """ + if resolved not in (None, ""): + return resolved + if not isinstance(data, dict) or not path: + return resolved + head = path[0] + if head not in ("response", "responseData", "context"): + return resolved + if head == "context" and len(path) != 1: + return resolved + fb = _ai_result_text_from_documents(data) + return fb if fb is not None else resolved + + def parseGraph(graph: Dict[str, Any]) -> Tuple[List[Dict], List[Dict], Set[str]]: """ Parse graph into nodes, connections, and node IDs. @@ -356,14 +400,15 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any: data = data.get("data", data) plist = list(path) resolved = _get_by_path(data, plist) - if ( - resolved is None - and isinstance(data, dict) - and plist - and plist[0] == "payload" - and len(plist) > 1 - ): - resolved = _get_by_path(data, plist[1:]) + if resolved is None and isinstance(data, dict) and plist: + if plist[0] == "payload" and len(plist) > 1: + # Strip explicit "payload" prefix (legacy DataPicker paths) + resolved = _get_by_path(data, plist[1:]) + elif "payload" in data and isinstance(data["payload"], dict): + # Form nodes store fields under {"payload": {fieldName: …}}. + # DataPicker emits bare field paths like ["url"]; try under payload. + resolved = _get_by_path(data["payload"], plist) + resolved = _ref_coalesce_empty_ai_result_text(data, plist, resolved) return resolveParameterReferences(resolved, nodeOutputs) return value if value.get("type") == "value": @@ -386,16 +431,27 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any: if len(parts) < 2: return json.dumps(data) if isinstance(data, (dict, list)) else str(data) rest = ".".join(parts[1:]) - if data is None: + + def _walk(root, keys): + cur = root + for k in keys: + if isinstance(cur, dict) and k in cur: + cur = cur[k] + elif isinstance(cur, (list, tuple)) and k.isdigit(): + cur = cur[int(k)] + else: + return None + return cur + + keys = rest.split(".") + result = _walk(data, keys) + # Form nodes store fields under {"payload": {field: …}}. + # Fall back to looking under "payload" when the direct path misses. + if result is None and isinstance(data, dict) and "payload" in data: + result = _walk(data["payload"], keys) + if result is None: return m.group(0) - for k in rest.split("."): - if isinstance(data, dict) and k in data: - data = data[k] - elif isinstance(data, (list, tuple)) and k.isdigit(): - data = data[int(k)] - else: - return m.group(0) - return str(data) if data is not None else m.group(0) + return str(result) if not isinstance(result, (dict, list)) else json.dumps(result, ensure_ascii=False) return re.sub(r"\{\{\s*([^}]+)\s*\}\}", repl, value) if isinstance(value, list): # contextBuilder: list where every item is a `{"type":"ref",...}` envelope. diff --git a/modules/workflows/methods/methodAi/_common.py b/modules/workflows/methods/methodAi/_common.py index d913f9e4..c2812a5c 100644 --- a/modules/workflows/methods/methodAi/_common.py +++ b/modules/workflows/methods/methodAi/_common.py @@ -11,12 +11,15 @@ def serialize_context(val: Any) -> str: """Convert any context value to a readable string for use in AI prompts. - None / empty string → "" + - empty dict (no keys) → "" (avoids literal "{}" in file.create / prompts) - str → as-is - dict / list → pretty-printed JSON - anything else → str() """ if val is None or val == "" or val == []: return "" + if isinstance(val, dict) and len(val) == 0: + return "" if isinstance(val, str): return val.strip() try: diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index a8fcaf0f..0edcd141 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -23,8 +23,10 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: documentList = parameters.get("documentList", []) documentType = parameters.get("documentType") - # Optional: if omitted, formats determined from prompt by AI - resultType = parameters.get("resultType") + # Prefer explicit outputFormat (flow UI); resultType remains for legacy / API callers. + resultType = parameters.get("outputFormat") or parameters.get("resultType") + if isinstance(resultType, str): + resultType = resultType.strip().lstrip(".").lower() or None if not resultType: logger.debug("resultType not provided - formats will be determined from prompt by AI") @@ -49,8 +51,12 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: else: docRefList = DocumentReferenceList(references=[]) - # Prepare title - title = parameters.get("documentType") or "Generated Document" + title_raw = parameters.get("title") + title = (title_raw.strip() if isinstance(title_raw, str) else "") or None + if not title and isinstance(documentType, str) and documentType.strip(): + title = documentType.strip() + if not title: + title = "Generated Document" # Call AI service for document generation # callAiContent handles documentList internally via Phases 5A-5E @@ -98,6 +104,8 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: "actionType": "ai.generateDocument", "documentType": documentType, "resultType": resultType, + "outputFormat": resultType, + "title": title, } )) @@ -119,14 +127,15 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: docName = sanitized # Determine mime type + rt = resultTypeFallback mimeType = "text/plain" - if resultType == "html": + if rt == "html": mimeType = "text/html" - elif resultType == "json": + elif rt == "json": mimeType = "application/json" - elif resultType == "pdf": + elif rt == "pdf": mimeType = "application/pdf" - elif resultType == "md": + elif rt == "md": mimeType = "text/markdown" documents.append(ActionDocument( @@ -137,6 +146,8 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: "actionType": "ai.generateDocument", "documentType": documentType, "resultType": resultType, + "outputFormat": resultType, + "title": title, } )) diff --git a/modules/workflows/methods/methodAi/methodAi.py b/modules/workflows/methods/methodAi/methodAi.py index ecd60b12..8afd6001 100644 --- a/modules/workflows/methods/methodAi/methodAi.py +++ b/modules/workflows/methods/methodAi/methodAi.py @@ -289,6 +289,30 @@ class MethodAi(MethodBase): required=True, description="Description of the document to generate" ), + "outputFormat": WorkflowActionParameter( + name="outputFormat", + type="str", + frontendType=FrontendType.SELECT, + frontendOptions=["docx", "pdf", "txt", "html", "md"], + required=False, + default="docx", + description="Rendered output format (same choices as file.create). If omitted alongside resultType, the model may infer format from the prompt." + ), + "title": WorkflowActionParameter( + name="title", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Document title / metadata (optional); used as generation title and for file naming hints." + ), + "context": WorkflowActionParameter( + name="context", + type="Any", + frontendType=FrontendType.TEXTAREA, + required=False, + default="", + description="Additional structured or text context from upstream steps; serialized into the prompt." + ), "documentList": WorkflowActionParameter( name="documentList", type="DocumentList", @@ -302,16 +326,15 @@ class MethodAi(MethodBase): frontendType=FrontendType.SELECT, frontendOptions=["letter", "memo", "proposal", "contract", "report", "email"], required=False, - description="Type of document" + description="Type of document (content hint for the model); used as title fallback when title is empty." ), "resultType": WorkflowActionParameter( name="resultType", type="str", frontendType=FrontendType.TEXT, required=False, - default="txt", - description="Output format (e.g., txt, html, pdf, docx, md, json, csv, xlsx, pptx, png, jpg). Optional: if omitted, formats are determined from prompt by AI. Default \"txt\" is validation fallback only. With per-document format determination, AI can determine different formats for different documents based on prompt." - ) + description="Legacy/API output format extension (e.g. txt, docx). Ignored when outputFormat is set." + ), }, execute=generateDocument.__get__(self, self.__class__) ), diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index 96804b10..2fef9e9e 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -35,6 +35,12 @@ def _persistDocumentsToUserFiles( return if not mgmt: return + logger.info( + "file.create persist: mgmt=%s id(mgmt)=%s has_createFileData=%s", + type(mgmt).__name__, + id(mgmt), + hasattr(mgmt, "createFileData"), + ) for doc in action_documents: try: doc_data = doc.documentData if hasattr(doc, "documentData") else doc.get("documentData") @@ -54,8 +60,15 @@ def _persistDocumentsToUserFiles( or doc.get("mimeType") or "application/octet-stream" ) + logger.info( + "file.create persist: calling createFile name=%s bytes=%s", + doc_name, + len(content), + ) file_item = mgmt.createFile(doc_name, mime, content) - mgmt.createFileData(file_item.id, content) + logger.info("file.create persist: createFile returned id=%s", file_item.id) + ok = mgmt.createFileData(file_item.id, content) + logger.info("file.create persist: createFileData returned %s for id=%s", ok, file_item.id) meta = getattr(doc, "validationMetadata", None) or doc.get("validationMetadata") or {} if isinstance(meta, dict): meta["fileId"] = file_item.id @@ -79,6 +92,10 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult: context = serialize_context(raw_context) if not context: + logger.warning( + "file.create: context empty after resolve — check DataRefs (e.g. Antworttext / " + "documents[0].documentData from the AI step)." + ) return ActionResult.isFailure(error="context is required (connect an AI node or provide text)") outputFormat = (parameters.get("outputFormat") or "docx").strip().lower().lstrip(".") From f184da9898da0a7afba557c4b687e7f9fa3d06bc Mon Sep 17 00:00:00 2001 From: Ida Date: Sun, 3 May 2026 19:03:25 +0200 Subject: [PATCH 04/14] fix: looping node and content extraction --- .../graphicalEditor/nodeDefinitions/ai.py | 18 +- .../graphicalEditor/nodeDefinitions/file.py | 10 +- .../graphicalEditor/nodeDefinitions/flow.py | 78 +++++--- modules/features/graphicalEditor/portTypes.py | 66 +++++++ .../services/serviceAi/subAiCallLooping.py | 104 ++++++---- .../services/serviceAi/subLoopingUseCases.py | 35 +++- .../services/serviceAi/subStructureFilling.py | 183 +++++++----------- .../services/serviceChat/mainServiceChat.py | 38 +++- .../renderers/rendererText.py | 54 ++++-- modules/shared/jsonContinuation.py | 27 +-- .../workflows/automation2/executionEngine.py | 7 +- .../executors/actionNodeExecutor.py | 112 ++++++++++- .../automation2/executors/ioExecutor.py | 8 +- .../methods/methodAi/actions/generateCode.py | 23 +-- .../methodAi/actions/generateDocument.py | 24 +-- 15 files changed, 522 insertions(+), 265 deletions(-) diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py index 4e29709e..43136394 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py @@ -59,7 +59,9 @@ AI_NODES = [ ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": [ + "FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult", + ]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -79,7 +81,7 @@ AI_NODES = [ ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}}, + "inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -98,7 +100,7 @@ AI_NODES = [ ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}}, + "inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-translate", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -118,7 +120,7 @@ AI_NODES = [ ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}}, + "inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}}, "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-convert", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -147,7 +149,9 @@ AI_NODES = [ ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": [ + "FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult", + ]}}, "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -171,7 +175,9 @@ AI_NODES = [ ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": [ + "FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult", + ]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True}, "_method": "ai", diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index 3b5ebfd4..ffa4d722 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -10,25 +10,17 @@ FILE_NODES = [ "label": t("Datei erstellen"), "description": t("Erstellt eine Datei aus Kontext (Text/Markdown von KI)."), "parameters": [ - {"name": "contentSources", "type": "json", "required": False, "frontendType": "json", - "description": t("Kontext-Quellen"), "default": []}, {"name": "outputFormat", "type": "str", "required": True, "frontendType": "select", "frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]}, "description": t("Ausgabeformat"), "default": "docx"}, {"name": "title", "type": "str", "required": False, "frontendType": "text", "description": t("Dokumenttitel")}, - {"name": "templateName", "type": "str", "required": False, "frontendType": "select", - "frontendOptions": {"options": ["default", "corporate", "minimal"]}, - "description": t("Stil-Vorlage")}, - {"name": "language", "type": "str", "required": False, "frontendType": "select", - "frontendOptions": {"options": ["de", "en", "fr"]}, - "description": t("Sprache"), "default": "de"}, {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", "description": t("Daten aus vorherigen Schritten"), "default": ""}, ], "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload"]}}, + "inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload", "LoopItem", "ActionResult"]}}, "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False}, "_method": "file", diff --git a/modules/features/graphicalEditor/nodeDefinitions/flow.py b/modules/features/graphicalEditor/nodeDefinitions/flow.py index 5ebf50bc..49a3dcaf 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/flow.py +++ b/modules/features/graphicalEditor/nodeDefinitions/flow.py @@ -3,25 +3,46 @@ from modules.shared.i18nRegistry import t +# Ports, die typische Schritt-Ausgaben durchreichen (nicht nur leerer Transit). +_FLOW_INPUT_SCHEMAS = [ + "Transit", + "FormPayload", + "AiResult", + "TextResult", + "ActionResult", + "DocumentList", + "FileList", + "EmailList", + "TaskList", + "QueryResult", + "MergeResult", + "LoopItem", + "BoolResult", + "UdmDocument", +] + FLOW_NODES = [ { "id": "flow.ifElse", "category": "flow", "label": t("Wenn / Sonst"), - "description": t("Verzweigung nach Bedingung"), + "description": t( + "Verzweigt anhand einer Bedingung auf ein vorheriges Feld oder einen Ausdruck. " + "Die Daten vom Eingangskanal werden an den gewählten Ausgang durchgereicht." + ), "parameters": [ { "name": "condition", - "type": "str", + "type": "json", "required": True, "frontendType": "condition", - "description": t("Bedingung"), + "description": t("Bedingung: Feld aus einem vorherigen Schritt und Vergleich"), }, ], "inputs": 1, "outputs": 2, "outputLabels": [t("Ja"), t("Nein")], - "inputPorts": {0: {"accepts": ["Transit"]}}, + "inputPorts": {0: {"accepts": list(_FLOW_INPUT_SCHEMAS)}}, "outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}}, "executor": "flow", "meta": {"icon": "mdi-source-branch", "color": "#FF9800", "usesAi": False}, @@ -30,26 +51,29 @@ FLOW_NODES = [ "id": "flow.switch", "category": "flow", "label": t("Switch"), - "description": t("Mehrere Zweige nach Wert"), + "description": t( + "Mehrere Zweige nach einem Wert aus einem vorherigen Schritt (Data Picker). " + "Definiere Fälle mit Vergleichsoperator; der Eingang wird an den ersten passenden Zweig durchgereicht." + ), "parameters": [ { "name": "value", - "type": "str", + "type": "Any", "required": True, - "frontendType": "text", - "description": t("Zu vergleichender Wert"), + "frontendType": "dataRef", + "description": t("Wert zum Vergleichen (Feld aus einem vorherigen Schritt)"), }, { "name": "cases", "type": "array", "required": False, "frontendType": "caseList", - "description": t("Fälle"), + "description": t("Fälle: Operator und Vergleichswert"), }, ], "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit"]}}, + "inputPorts": {0: {"accepts": list(_FLOW_INPUT_SCHEMAS)}}, "outputPorts": {0: {"schema": "Transit"}}, "executor": "flow", "meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800", "usesAi": False}, @@ -57,15 +81,18 @@ FLOW_NODES = [ { "id": "flow.loop", "category": "flow", - "label": t("Schleife / Für Jedes"), - "description": t("Über Array-Elemente oder UDM-Strukturebenen iterieren"), + "label": t("Schleife / Für jedes"), + "description": t( + "Iteriert über ein Array aus einem vorherigen Schritt (z. B. documente, Zeilen, Listeneinträge). " + "Optional: UDM-Ebene für strukturierte Dokumente." + ), "parameters": [ { "name": "items", - "type": "str", + "type": "Any", "required": True, - "frontendType": "text", - "description": t("Pfad zum Array"), + "frontendType": "dataRef", + "description": t("Liste oder Sammlung zum Durchlaufen (im Data Picker wählen)"), }, { "name": "level", @@ -73,7 +100,7 @@ FLOW_NODES = [ "required": False, "frontendType": "select", "frontendOptions": {"options": ["auto", "documents", "structuralNodes", "contentBlocks"]}, - "description": t("UDM-Iterationsebene"), + "description": t("Nur bei UDM-Daten: welche Strukturebene als Elemente verwendet wird"), "default": "auto", }, { @@ -82,14 +109,15 @@ FLOW_NODES = [ "required": False, "frontendType": "number", "frontendOptions": {"min": 1, "max": 20}, - "description": t("Parallele Iterationen (1 = sequentiell)"), + "description": t("Parallele Durchläufe (1 = nacheinander)"), "default": 1, }, ], "inputs": 1, "outputs": 1, "inputPorts": {0: {"accepts": [ - "Transit", "UdmDocument", "EmailList", "DocumentList", "FileList", "TaskList", "ActionResult", + "Transit", "UdmDocument", "EmailList", "DocumentList", "FileList", "TaskList", + "ActionResult", "AiResult", "QueryResult", "FormPayload", ]}}, "outputPorts": {0: {"schema": "LoopItem"}}, "executor": "flow", @@ -99,7 +127,10 @@ FLOW_NODES = [ "id": "flow.merge", "category": "flow", "label": t("Zusammenführen"), - "description": t("Mehrere Zweige zusammenführen (2-5 Eingänge)"), + "description": t( + "Führt 2–5 Zweige zusammen, wenn alle verbunden sind. " + "Modus legt fest, wie die Eingabeobjekte im Ergebnis kombiniert werden." + ), "parameters": [ { "name": "mode", @@ -107,7 +138,7 @@ FLOW_NODES = [ "required": False, "frontendType": "select", "frontendOptions": {"options": ["first", "all", "append"]}, - "description": t("Zusammenführungsmodus"), + "description": t("first: erster Zweig; all: Dict-Felder zusammenführen; append: Listen anhängen"), "default": "first", }, { @@ -116,13 +147,16 @@ FLOW_NODES = [ "required": False, "frontendType": "number", "frontendOptions": {"min": 2, "max": 5}, - "description": t("Anzahl Eingänge"), + "description": t("Anzahl Eingänge dieses Nodes (2–5)"), "default": 2, }, ], "inputs": 2, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit"]}, 1: {"accepts": ["Transit"]}}, + "inputPorts": { + 0: {"accepts": list(_FLOW_INPUT_SCHEMAS)}, + 1: {"accepts": list(_FLOW_INPUT_SCHEMAS)}, + }, "outputPorts": {0: {"schema": "MergeResult"}}, "executor": "flow", "meta": {"icon": "mdi-call-merge", "color": "#FF9800", "usesAi": False}, diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index bd092745..deab83b9 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -644,6 +644,69 @@ def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any: # Output normalizers # --------------------------------------------------------------------------- +def _file_record_to_document(f: Any) -> Optional[Dict[str, Any]]: + """Map API / task-upload file dicts onto PortSchema ``Document`` fields.""" + if f is None: + return None + if isinstance(f, str) and f.strip(): + return {"id": f.strip()} + if not isinstance(f, dict): + return None + inner = f.get("file") if isinstance(f.get("file"), dict) else None + src = inner or f + out: Dict[str, Any] = {} + fid = src.get("id") or f.get("id") + if fid is not None and str(fid).strip(): + out["id"] = str(fid).strip() + name = ( + src.get("name") + or src.get("fileName") + or f.get("fileName") + or f.get("name") + ) + if name is not None and str(name).strip(): + out["name"] = str(name).strip() + mime = src.get("mimeType") or src.get("mime") or f.get("mimeType") + if mime is not None and str(mime).strip(): + out["mimeType"] = str(mime).strip() + for k in ("sizeBytes", "downloadUrl", "filePath"): + v = src.get(k) if k in src else f.get(k) + if v is not None and v != "": + out[k] = v + return out if out else None + + +def _coerce_document_list_upload_fields(result: Dict[str, Any]) -> None: + """ + Human task ``input.upload`` completes with ``file`` / ``files`` / ``fileIds``. + DocumentList expects ``documents``. Without this, resume adds ``documents: []`` and drops the real files. + """ + docs = result.get("documents") + if isinstance(docs, list) and len(docs) > 0: + return + collected: List[Dict[str, Any]] = [] + files = result.get("files") + if isinstance(files, list): + for item in files: + d = _file_record_to_document(item) + if d: + collected.append(d) + if not collected: + single = result.get("file") + d = _file_record_to_document(single) + if d: + collected.append(d) + if not collected and isinstance(result.get("fileIds"), list): + for fid in result["fileIds"]: + if fid is not None and str(fid).strip(): + collected.append({"id": str(fid).strip()}) + if not collected: + return + result["documents"] = collected + if not result.get("count"): + result["count"] = len(collected) + + def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]: """ Normalize raw executor output to match the declared port schema. @@ -660,6 +723,9 @@ def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]: if not schema or schemaName == "Transit": return result + if schemaName == "DocumentList": + _coerce_document_list_upload_fields(result) + # Only default **required** fields. Optional fields stay absent so DataRefs / context # resolution never pick a synthetic `{}` or `[]` (e.g. AiResult.responseData when the # model returned plain text only). diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index cc3a014b..d532115d 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -57,8 +57,7 @@ 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.jsonUtils import buildContinuationContext, extractJsonString, tryParseJson -from modules.shared.jsonUtils import tryParseJson +from modules.shared.jsonUtils import buildContinuationContext, tryParseJson from modules.shared.jsonUtils import closeJsonStructures from modules.shared.jsonUtils import stripCodeFences, normalizeJsonText @@ -374,9 +373,8 @@ class AiCallLooper: if lastValidCompletePart: try: - extracted = extractJsonString(lastValidCompletePart) - parsed, parseErr, _ = tryParseJson(extracted) - if parseErr is None and parsed: + parsed, parseErr, _ = tryParseJson(lastValidCompletePart) + if parseErr is None: normalized = self._normalizeJsonStructure(parsed, useCase) return json.dumps(normalized, indent=2, ensure_ascii=False) except Exception: @@ -404,11 +402,10 @@ class AiCallLooper: # This ensures retry iterations use the correct base context lastRawResponse = candidateJson - # Try direct parse of candidate + # Try direct parse of candidate (same pipeline as structure filling / getContexts) try: - extracted = extractJsonString(candidateJson) - parsed, parseErr, _ = tryParseJson(extracted) - if parseErr is None and parsed: + parsed, parseErr, extracted = tryParseJson(candidateJson) + if parseErr is None: # Direct parse succeeded - FINISHED # Commit candidate to jsonBase jsonBase = candidateJson @@ -441,39 +438,50 @@ class AiCallLooper: # STEP 6: DECIDE based on jsonParsingSuccess and overlapContext if contexts.jsonParsingSuccess and contexts.overlapContext == "": - # JSON is complete (no cut point) - FINISHED - # Use completePart for final result (closed, repaired JSON) - # No more merging needed, so we don't need the cut version - jsonBase = contexts.completePart + # getContexts and downstream must agree with tryParseJson (same as structure filling). logger.info(f"Iteration {iteration}: jsonParsingSuccess=true, overlapContext='', JSON complete") - # Store and parse completePart lastValidCompletePart = contexts.completePart try: - extracted = extractJsonString(contexts.completePart) - parsed, parseErr, _ = tryParseJson(extracted) - if parseErr is None and parsed: - normalized = self._normalizeJsonStructure(parsed, useCase) - result = json.dumps(normalized, indent=2, ensure_ascii=False) - - if iterationOperationId: - self.services.chat.progressLogFinish(iterationOperationId, True) - - if not useCase.finalResultHandler: - raise ValueError( - f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback." - ) - return useCase.finalResultHandler( - result, normalized, extracted, debugPrefix, self.services + parsed, parseErr, extracted = tryParseJson(contexts.completePart) + if parseErr is not None: + raise ValueError(str(parseErr)) + normalized = self._normalizeJsonStructure(parsed, useCase) + result = json.dumps(normalized, indent=2, ensure_ascii=False) + jsonBase = contexts.completePart + + if iterationOperationId: + self.services.chat.progressLogFinish(iterationOperationId, True) + + if not useCase.finalResultHandler: + raise ValueError( + f"Use case '{useCaseId}' is missing required 'finalResultHandler' callback." ) + return useCase.finalResultHandler( + result, normalized, extracted, debugPrefix, self.services + ) except Exception as e: - logger.warning(f"Iteration {iteration}: Failed to parse completePart: {e}") - - # Fallback: return completePart as-is - if iterationOperationId: - self.services.chat.progressLogFinish(iterationOperationId, True) - return contexts.completePart + logger.warning( + f"Iteration {iteration}: completePart not serializable after getContexts success: {e}" + ) + mergeFailCount += 1 + if mergeFailCount >= MAX_MERGE_FAILS: + logger.error( + f"Iteration {iteration}: Max failures ({MAX_MERGE_FAILS}) " + "after output pipeline mismatch" + ) + if iterationOperationId: + self.services.chat.progressLogFinish(iterationOperationId, False) + return jsonBase if jsonBase else "" + if iterationOperationId: + self.services.chat.progressLogUpdate( + iterationOperationId, + 0.7, + f"Output pipeline failed ({mergeFailCount}/{MAX_MERGE_FAILS}), retrying", + ) + self.services.chat.progressLogFinish(iterationOperationId, True) + continue elif contexts.jsonParsingSuccess and contexts.overlapContext != "": # JSON parseable but has cut point - CONTINUE to next iteration @@ -522,9 +530,8 @@ class AiCallLooper: if lastValidCompletePart: try: - extracted = extractJsonString(lastValidCompletePart) - parsed, parseErr, _ = tryParseJson(extracted) - if parseErr is None and parsed: + parsed, parseErr, _ = tryParseJson(lastValidCompletePart) + if parseErr is None: normalized = self._normalizeJsonStructure(parsed, useCase) return json.dumps(normalized, indent=2, ensure_ascii=False) except Exception: @@ -552,9 +559,24 @@ class AiCallLooper: if iteration >= maxIterations: logger.warning(f"AI call stopped after maximum iterations ({maxIterations})") - # This code path should never be reached because all registered use cases - # return early when JSON is complete. This would only execute for use cases that - # require section extraction, but no such use cases are currently registered. + # Prefer last repaired complete JSON from getContexts (raw `result` is only the last fragment). + if lastValidCompletePart and useCase and not useCase.requiresExtraction: + try: + parsed, parseErr, extracted = tryParseJson(lastValidCompletePart) + if parseErr is None: + normalized = self._normalizeJsonStructure(parsed, useCase) + out = json.dumps(normalized, indent=2, ensure_ascii=False) + if useCase.finalResultHandler: + logger.warning( + "callAiWithLooping: max iterations — returning last valid completePart for %r", + useCaseId, + ) + return useCase.finalResultHandler( + out, normalized, extracted, debugPrefix, self.services + ) + except Exception as e: + logger.debug("Max-iterations fallback on completePart failed: %s", e) + logger.error( "End of callAiWithLooping without success for use case %r (iterations=%s, lastResultLen=%s)", useCaseId, diff --git a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py index a2828108..f6a7c620 100644 --- a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py +++ b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py @@ -54,6 +54,15 @@ def _handleCodeContentFinalResult(result: str, parsedJsonForUseCase: Any, extrac return final_json +def _lift_section_plain_text(d: Dict[str, Any]) -> Optional[str]: + """Models often return {\"text\": \"...\"} without an elements array; extract usable prose.""" + for key in ("text", "body", "summary", "response", "output", "answer", "message", "content"): + v = d.get(key) + if isinstance(v, str) and v.strip(): + return v.strip() + return None + + def _normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any: """Normalize JSON structure for section_content use case.""" # For section_content, expect {"elements": [...]} structure @@ -77,15 +86,29 @@ def _normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any: # Convert plain list of elements to elements structure return {"elements": parsed} elif isinstance(parsed, dict): - # If it already has "elements", return as-is if "elements" in parsed: + els = parsed.get("elements") + if isinstance(els, list) and len(els) > 0: + return parsed + lifted = _lift_section_plain_text(parsed) + if lifted: + out = dict(parsed) + out["elements"] = [{"type": "paragraph", "content": {"text": lifted}}] + logger.info( + "section_content: promoted plain-text field to elements (%d chars)", + len(lifted), + ) + return out return parsed - # If it has "type" and looks like an element, wrap in elements array - elif parsed.get("type"): + if parsed.get("type"): return {"elements": [parsed]} - # Otherwise, assume it's already in correct format - else: - return parsed + lifted = _lift_section_plain_text(parsed) + if lifted: + return { + **parsed, + "elements": [{"type": "paragraph", "content": {"text": lifted}}], + } + return parsed # For other use cases, return as-is (they have their own structures) return parsed diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py index b31bc32d..33398b64 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py +++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py @@ -27,6 +27,36 @@ class _AiResponseFallback: logger = logging.getLogger(__name__) +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 + + if parsed is None: + return [] + if isinstance(parsed, dict): + has_nonempty_elements = ( + isinstance(parsed.get("elements"), list) and len(parsed["elements"]) > 0 + ) + if not has_nonempty_elements: + # Valid full-document envelope (same normalized shape the renderer uses elsewhere) + docs = parsed.get("documents") + if isinstance(docs, list) and docs and isinstance(docs[0], dict): + secs = docs[0].get("sections") + if isinstance(secs, list) and secs and isinstance(secs[0], dict): + parsed = secs[0] + elif ( + isinstance(parsed.get("sections"), list) + and parsed["sections"] + and isinstance(parsed["sections"][0], dict) + ): + parsed = parsed["sections"][0] + norm = _normalizeSectionContentJson(parsed, "section_content") + if isinstance(norm, dict): + els = norm.get("elements") + return list(els) if isinstance(els, list) else [] + return [] + + class StructureFiller: """Handles filling document structure with content.""" @@ -524,38 +554,12 @@ class StructureFiller: if generatedElements: elements.extend(generatedElements) else: - # Fallback: Try to parse JSON response directly with repair logic - try: - from modules.shared.jsonUtils import tryParseJson, repairBrokenJson - - # Use tryParseJson which handles extraction and basic parsing - fallbackElements, parseError, cleanedStr = tryParseJson(aiResponse.content) - - # If parsing failed, try repair - if parseError and isinstance(aiResponse.content, str): - logger.warning(f"Initial JSON parse failed for section {sectionId}, attempting repair: {str(parseError)}") - repairedJson = repairBrokenJson(aiResponse.content) - if repairedJson: - fallbackElements = repairedJson - parseError = None - logger.info(f"Successfully repaired JSON for section {sectionId}") - - if parseError: - raise parseError - - if isinstance(fallbackElements, list): - elements.extend(fallbackElements) - elif isinstance(fallbackElements, dict) and "elements" in fallbackElements: - elements.extend(fallbackElements["elements"]) - elif isinstance(fallbackElements, dict) and fallbackElements.get("type"): - elements.append(fallbackElements) - except (json.JSONDecodeError, ValueError) as json_error: - logger.error(f"Error parsing JSON response for section {sectionId}: {str(json_error)}") - elements.append({ - "type": "error", - "message": f"Failed to parse JSON response: {str(json_error)}", - "sectionId": sectionId - }) + logger.error(f"No elements produced for section {sectionId} (callAiWithLooping must return parseable JSON)") + elements.append({ + "type": "error", + "message": f"No parsed content for section {sectionId}", + "sectionId": sectionId + }) return elements @@ -671,7 +675,7 @@ class StructureFiller: try: self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation") - operationType = OperationTypeEnum.DATA_ANALYSE + operationType = OperationTypeEnum.DATA_GENERATE options = AiCallOptions( operationType=operationType, priority=PriorityEnum.BALANCED, @@ -703,22 +707,17 @@ class StructureFiller: ) try: - from modules.shared.jsonUtils import tryParseJson, repairBrokenJson + from modules.shared.jsonUtils import tryParseJson + if isinstance(aiResponseJson, str) and ("---" in aiResponseJson or aiResponseJson.count("```json") > 1): generatedElements = self._extractAndMergeMultipleJsonBlocks(aiResponseJson, contentType, sectionId) else: - parsedResponse, parseError, cleanedStr = tryParseJson(aiResponseJson) - if parsedResponse is None: - logger.warning(f"Section {sectionId}: tryParseJson failed, attempting repair") - repairedStr = repairBrokenJson(aiResponseJson) - parsedResponse, parseError2, _ = tryParseJson(repairedStr) - - if parsedResponse and isinstance(parsedResponse, dict): - generatedElements = parsedResponse.get("elements", []) - elif parsedResponse and isinstance(parsedResponse, list): - generatedElements = parsedResponse - else: + parsedResponse, parseError, _ = tryParseJson(aiResponseJson) + if parseError is not None: + logger.error(f"Section {sectionId}: tryParseJson failed: {parseError}") generatedElements = [] + else: + generatedElements = _elements_from_section_content_ai_json(parsedResponse) except Exception as parseErr: logger.error(f"Section {sectionId}: JSON parse error: {parseErr}") generatedElements = [] @@ -930,7 +929,7 @@ class StructureFiller: self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation") - operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE + operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE if operationType == OperationTypeEnum.IMAGE_GENERATE: maxPromptLength = 4000 @@ -996,44 +995,17 @@ class StructureFiller: ) try: - # Use tryParseJson which handles extraction and basic parsing - from modules.shared.jsonUtils import tryParseJson, repairBrokenJson - - # Check if response contains multiple JSON blocks (separated by --- or multiple ```json blocks) - # This can happen when AI returns multiple complete responses + from modules.shared.jsonUtils import tryParseJson + if isinstance(aiResponseJson, str) and ("---" in aiResponseJson or aiResponseJson.count("```json") > 1): logger.info(f"Section {sectionId}: Detected multiple JSON blocks in response, attempting to merge") generatedElements = self._extractAndMergeMultipleJsonBlocks(aiResponseJson, contentType, sectionId) else: - parsedResponse, parseError, cleanedStr = tryParseJson(aiResponseJson) - - # If parsing failed, try repair - if parseError and isinstance(aiResponseJson, str): - logger.warning(f"Initial JSON parse failed for section {sectionId}, attempting repair: {str(parseError)}") - repairedJson = repairBrokenJson(aiResponseJson) - if repairedJson: - parsedResponse = repairedJson - parseError = None - logger.info(f"Successfully repaired JSON for section {sectionId}") - - if parseError: + parsedResponse, parseError, _ = tryParseJson(aiResponseJson) + if parseError is not None: raise parseError - - if isinstance(parsedResponse, list): - generatedElements = parsedResponse - elif isinstance(parsedResponse, dict): - if "elements" in parsedResponse: - generatedElements = parsedResponse["elements"] - elif "sections" in parsedResponse and len(parsedResponse["sections"]) > 0: - firstSection = parsedResponse["sections"][0] - generatedElements = firstSection.get("elements", []) - elif parsedResponse.get("type"): - generatedElements = [parsedResponse] - else: - generatedElements = [] - else: - generatedElements = [] - + generatedElements = _elements_from_section_content_ai_json(parsedResponse) + aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") @@ -1112,7 +1084,7 @@ class StructureFiller: self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation") - operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE + operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE if operationType == OperationTypeEnum.IMAGE_GENERATE: maxPromptLength = 4000 @@ -1135,6 +1107,7 @@ class StructureFiller: processingMode=ProcessingModeEnum.DETAILED ) ) + checkWorkflowStopped(self.services) aiResponse = await self.aiService.callAi(request) generatedElements = [] @@ -1179,22 +1152,16 @@ class StructureFiller: ) try: - parsedResponse = json.loads(self.services.utils.jsonExtractString(aiResponseJson)) - if isinstance(parsedResponse, list): - generatedElements = parsedResponse - elif isinstance(parsedResponse, dict): - if "elements" in parsedResponse: - generatedElements = parsedResponse["elements"] - elif "sections" in parsedResponse and len(parsedResponse["sections"]) > 0: - firstSection = parsedResponse["sections"][0] - generatedElements = firstSection.get("elements", []) - elif parsedResponse.get("type"): - generatedElements = [parsedResponse] - else: - generatedElements = [] - else: + from modules.shared.jsonUtils import tryParseJson + + parsedResponse, parseError, _ = tryParseJson(aiResponseJson) + if parseError is not None: + logger.error( + f"Error parsing response from _callAiWithLooping for section {sectionId}: {parseError}" + ) generatedElements = [] - + else: + generatedElements = _elements_from_section_content_ai_json(parsedResponse) aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") @@ -1371,7 +1338,7 @@ class StructureFiller: self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation") - operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE + operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE if operationType == OperationTypeEnum.IMAGE_GENERATE: maxPromptLength = 4000 @@ -1439,22 +1406,16 @@ class StructureFiller: ) try: - parsedResponse = json.loads(self.services.utils.jsonExtractString(aiResponseJson)) - if isinstance(parsedResponse, list): - generatedElements = parsedResponse - elif isinstance(parsedResponse, dict): - if "elements" in parsedResponse: - generatedElements = parsedResponse["elements"] - elif "sections" in parsedResponse and len(parsedResponse["sections"]) > 0: - firstSection = parsedResponse["sections"][0] - generatedElements = firstSection.get("elements", []) - elif parsedResponse.get("type"): - generatedElements = [parsedResponse] - else: - generatedElements = [] - else: + from modules.shared.jsonUtils import tryParseJson + + parsedResponse, parseError, _ = tryParseJson(aiResponseJson) + if parseError is not None: + logger.error( + f"Error parsing response from _callAiWithLooping for section {sectionId}: {parseError}" + ) generatedElements = [] - + else: + generatedElements = _elements_from_section_content_ai_json(parsedResponse) aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index fcf9be2f..b5c7a542 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -40,6 +40,26 @@ class ChatService: """Workflow from context (stable during workflow execution).""" return self._context.workflow + def _chat_document_from_management_file(self, file_id: str) -> Optional[ChatDocument]: + """Build a ChatDocument when docItem references a management FileItem (e.g. automation uploads) without a chat message.""" + try: + fi = self.interfaceDbComponent.getFile(file_id) + except Exception as e: + logger.debug("getFile(%s) failed: %s", file_id, e) + return None + if fi is None: + return None + wf = self._workflow + wf_id = wf.id if wf else "no-workflow" + return ChatDocument( + id=file_id, + messageId=f"_filestore:{wf_id}", + fileId=fi.id, + fileName=fi.fileName or "document", + fileSize=int(fi.fileSize or 0), + mimeType=fi.mimeType or "application/octet-stream", + ) + def getChatDocumentsFromDocumentList(self, documentList) -> List[ChatDocument]: """Get ChatDocuments from a DocumentReferenceList. @@ -130,14 +150,28 @@ class ChatService: if message.documents: for doc in message.documents: - if doc.id == docId: + if doc.id == docId or getattr(doc, "fileId", None) == docId: allDocuments.append(doc) docFound = True - logger.debug(f"Matched document reference '{docRef}' to document {doc.id} (fileName: {getattr(doc, 'fileName', 'unknown')}) by documentId") + logger.debug( + f"Matched document reference '{docRef}' to document {doc.id} " + f"(fileName: {getattr(doc, 'fileName', 'unknown')}) by id/fileId" + ) break if docFound: break + if not docFound: + synth = self._chat_document_from_management_file(docId) + if synth is not None: + allDocuments.append(synth) + docFound = True + logger.info( + "Resolved document reference %r via FileItem %s (automation / transient workflow)", + docRef, + docId, + ) + # Fallback: If not found by documentId and it looks like a filename (has file extension), try filename matching # This handles cases where AI incorrectly generates docItem:filename.docx if not docFound and '.' in docId and len(parts) == 2: diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py index 596feeeb..67eab4e8 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py @@ -196,8 +196,10 @@ class RendererText(BaseRenderer): textParts.append(f"[Reference: {label}]") continue elif element_type == "extracted_text": - # Extracted text format + # Extracted text format (str or raw bytes from ContentPart) content = element.get("content", "") + if isinstance(content, (bytes, bytearray, memoryview)): + content = bytes(content).decode("utf-8", errors="replace") source = element.get("source", "") if content: source_text = f" (Source: {source})" if source else "" @@ -323,22 +325,27 @@ class RendererText(BaseRenderer): try: # Extract from nested content structure: element.content.{text, level} content = headingData.get("content", {}) - if not isinstance(content, dict): + if isinstance(content, dict) and content: + text = self._stripMarkdownForPlainText(content.get("text", "")) + level = content.get("level", 1) + else: + # AI shorthand: {"type":"heading","text":"...","level":2} + text = self._stripMarkdownForPlainText(str(headingData.get("text", "") or "")) + level = headingData.get("level", 1) + if not text: return "" - text = self._stripMarkdownForPlainText(content.get("text", "")) - level = content.get("level", 1) - - if text: - level = max(1, min(6, level)) - if level == 1: - return f"{text}\n{'=' * len(text)}" - elif level == 2: - return f"{text}\n{'-' * len(text)}" - else: - return f"{'#' * level} {text}" - - return "" - + + try: + level_i = int(level) if level is not None else 1 + except (TypeError, ValueError): + level_i = 1 + level_i = max(1, min(6, level_i)) + if level_i == 1: + return f"{text}\n{'=' * len(text)}" + if level_i == 2: + return f"{text}\n{'-' * len(text)}" + return f"{'#' * level_i} {text}" + except Exception as e: self.logger.warning(f"Error rendering heading: {str(e)}") return "" @@ -399,8 +406,19 @@ class RendererText(BaseRenderer): def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str: """Render a JSON paragraph to text. Strips markdown for plain text output.""" try: - # Extract from nested content structure - content = paragraphData.get("content", {}) + # Models often return {"type":"paragraph","text":"..."} without nested "content" + top = paragraphData.get("text") + raw_content = paragraphData.get("content", {}) + if isinstance(top, str) and top.strip(): + if raw_content is None or raw_content == {}: + return self._stripMarkdownForPlainText(top) + if isinstance(raw_content, dict): + if not (raw_content.get("text") or raw_content.get("inlineRuns")): + return self._stripMarkdownForPlainText(top) + + content = raw_content + if content is None: + content = {} if isinstance(content, dict): runs = self._inlineRunsFromContent(content) if runs: diff --git a/modules/shared/jsonContinuation.py b/modules/shared/jsonContinuation.py index 22180b41..9d282c62 100644 --- a/modules/shared/jsonContinuation.py +++ b/modules/shared/jsonContinuation.py @@ -2172,11 +2172,13 @@ def getContexts( >>> print(contexts.overlapContext) # "" (empty - JSON is complete) >>> print(contexts.jsonParsingSuccess) # True """ - # First, check if original JSON is already complete (parseable without modification) + # Completeness must use the same pipeline as callers (fences, balanced extract, normalization). + from modules.shared.jsonUtils import tryParseJson as _utils_try_parse_json + jsonIsComplete = False if truncatedJson and truncatedJson.strip(): - parsed, error = _tryParseJson(truncatedJson.strip()) - if error is None: + _parsed_hdr, error_hdr, _ = _utils_try_parse_json(truncatedJson) + if error_hdr is None: jsonIsComplete = True logger.debug("Original JSON is already complete (no cut point)") @@ -2193,28 +2195,27 @@ def getContexts( jsonParsingSuccess = False if completePart and completePart.strip(): - # First attempt: parse as-is - parsed, error = _tryParseJson(completePart) - + parsed, error, _ = _utils_try_parse_json(completePart) if error is None: jsonParsingSuccess = True else: - # Second attempt: repair internal errors and retry - logger.debug(f"Initial parse failed: {error}, attempting repair") + logger.debug(f"Initial parse failed: {error}, attempting internal repair") repairedCompletePart = _repairInternalJsonErrors(completePart) - - parsed, error = _tryParseJson(repairedCompletePart) - + parsed, error, _ = _utils_try_parse_json(repairedCompletePart) if error is None: - # Repair succeeded - use repaired version completePart = repairedCompletePart jsonParsingSuccess = True logger.debug("JSON repair successful") else: - # Repair also failed - keep original completePart, mark as failed logger.debug(f"JSON repair also failed: {error}") jsonParsingSuccess = False + # If completePart parses successfully, the merged/candidate JSON is structurally complete + # after repair/closing — overlap from extractContinuationContexts on the *raw* candidate + # would falsely signal truncation and trap callAiWithLooping in continuation iterations. + if jsonParsingSuccess: + overlap = "" + return JsonContinuationContexts( overlapContext=overlap, hierarchyContext=hierarchy, diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py index 55a63281..e49754f8 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflows/automation2/executionEngine.py @@ -393,9 +393,10 @@ async def executeGraph( ordered_ids = [n.get("id") for n in ordered if n.get("id")] logger.info("executeGraph topoSort order: %s", ordered_ids) - nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {}) + # Normalize resumed human-node output BEFORE copying into nodeOutputs — otherwise + # normalizeToSchema only updates initialNodeOutputs and loop/refs still see raw + # e.g. input.upload {files} without coerced DocumentList.documents. is_resume = startAfterNodeId is not None - if is_resume and initialNodeOutputs and startAfterNodeId: resumedNode = next((n for n in nodes if n.get("id") == startAfterNodeId), None) if resumedNode: @@ -408,6 +409,8 @@ async def executeGraph( initialNodeOutputs[startAfterNodeId] = normalizeToSchema(resumedOutput, schema) except Exception as valErr: logger.warning("executeGraph resume: schema validation failed for %s: %s", startAfterNodeId, valErr) + + nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {}) if not runId and automation2_interface and workflowId and not is_resume: run_context = { "connectionMap": connectionMap, diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index fe9fa13e..2806bd4c 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -1,7 +1,8 @@ # Copyright (c) 2025 Patrick Motsch # Action node executor - maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions. # -# Typed Port System: explicit DataRefs / static parameters only (no runtime wire-handover). +# Typed Port System: explicit DataRefs / static parameters; optional ``documentList`` from input port 0 +# when the param is empty (same idea as IOExecutor wire fill). # ``materializeConnectionRefs`` (see pickNotPushMigration) may still rewrite empty connectionReference at run start. import json @@ -18,6 +19,25 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil logger = logging.getLogger(__name__) + +def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]: + """Normalize documentData (bytes/str/buffer) for DB file persistence.""" + 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): + b = raw.encode("utf-8") + return b if len(b) > 0 else None + return None + + _USER_CONNECTION_ID_RE = 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, @@ -219,6 +239,78 @@ def _getOutputSchemaName(nodeDef: Dict) -> str: return port0.get("schema", "ActionResult") +def _extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]: + """ + Build a DocumentList-shaped dict from upstream node output (matches IOExecutor wire behavior). + Handles DocumentList, human upload shapes (file / files / fileIds), FileList, loop file items. + During flow.loop body execution the loop node's output is + {items, count, currentItem, currentIndex}; wired document actions must use currentItem. + """ + if inp is None: + return None + from modules.features.graphicalEditor.portTypes import ( + unwrapTransit, + _coerce_document_list_upload_fields, + _file_record_to_document, + ) + + data = unwrapTransit(inp) + if isinstance(data, str): + one = _file_record_to_document(data) + return {"documents": [one], "count": 1} if one else None + if not isinstance(data, dict): + return None + d = dict(data) + _coerce_document_list_upload_fields(d) + # Per-iteration payload from executionEngine (flow.loop → downstream in loop body) + if "currentItem" in d: + ci = d.get("currentItem") + if ci is not None: + nested = _extract_wired_document_list(ci) + if nested: + return nested + docs = d.get("documents") + if isinstance(docs, list) and len(docs) > 0: + return {"documents": docs, "count": d.get("count", len(docs))} + raw_list = d.get("documentList") + if isinstance(raw_list, list) and len(raw_list) > 0 and isinstance(raw_list[0], dict): + return {"documents": raw_list, "count": len(raw_list)} + doc_id = d.get("documentId") or d.get("id") + if doc_id and str(doc_id).strip(): + one: Dict[str, Any] = {"id": str(doc_id).strip()} + fn = d.get("fileName") or d.get("name") + if fn: + one["name"] = str(fn) + mt = d.get("mimeType") + if mt: + one["mimeType"] = str(mt) + return {"documents": [one], "count": 1} + files = d.get("files") + if isinstance(files, list) and files: + collected = [] + for item in files: + conv = _file_record_to_document(item) if isinstance(item, dict) else None + if conv: + collected.append(conv) + if collected: + return {"documents": collected, "count": len(collected)} + return None + + +def _document_list_param_is_empty(val: Any) -> bool: + if val is None or val == "": + return True + if isinstance(val, list) and len(val) == 0: + return True + if isinstance(val, dict): + if val.get("documents") or val.get("references") or val.get("items"): + return False + if val.get("documentId") or val.get("id"): + return False + return True + return False + + class ActionNodeExecutor: """Execute action nodes by mapping to method actions via ActionExecutor.""" @@ -260,6 +352,17 @@ class ActionNodeExecutor: if pName and pName not in resolvedParams and "default" in pDef: resolvedParams[pName] = pDef["default"] + _param_names = {p.get("name") for p in nodeDef.get("parameters", []) if p.get("name")} + if "documentList" in _param_names and _document_list_param_is_empty(resolvedParams.get("documentList")): + _src_map = (context.get("inputSources") or {}).get(nodeId) or {} + _entry = _src_map.get(0) + if _entry: + _src_node_id, _ = _entry + _upstream = (context.get("nodeOutputs") or {}).get(_src_node_id) + _wired = _extract_wired_document_list(_upstream) + if _wired: + resolvedParams["documentList"] = _wired + # 3. Resolve connectionReference chatService = getattr(self.services, "chat", None) _resolveConnectionParam(resolvedParams, chatService, self.services) @@ -323,7 +426,8 @@ class ActionNodeExecutor: for d in (result.documents or []): dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None) - if isinstance(dumped, dict) and isinstance(rawData, bytes) and len(rawData) > 0: + rawBytes = _coerce_document_data_to_bytes(rawData) + if isinstance(dumped, dict) and rawBytes: try: from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface @@ -347,8 +451,8 @@ class ActionNodeExecutor: _mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId) _docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin" _mimeType = dumped.get("mimeType") or "application/octet-stream" - _fileItem = _mgmt.createFile(_docName, _mimeType, rawData) - _mgmt.createFileData(_fileItem.id, rawData) + _fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes) + _mgmt.createFileData(_fileItem.id, rawBytes) dumped["fileId"] = _fileItem.id dumped["id"] = _fileItem.id dumped["fileName"] = _fileItem.fileName diff --git a/modules/workflows/automation2/executors/ioExecutor.py b/modules/workflows/automation2/executors/ioExecutor.py index 38e2570c..f6d40b05 100644 --- a/modules/workflows/automation2/executors/ioExecutor.py +++ b/modules/workflows/automation2/executors/ioExecutor.py @@ -45,10 +45,12 @@ class IOExecutor: if 0 in inputSources: srcId, _ = inputSources[0] inp = nodeOutputs.get(srcId) - from modules.workflows.automation2.executors.actionNodeExecutor import _getDocumentsFromUpstream - docs = _getDocumentsFromUpstream(inp) if isinstance(inp, dict) else [] + from modules.workflows.automation2.executors.actionNodeExecutor import _extract_wired_document_list + + wired = _extract_wired_document_list(inp) + docs = (wired or {}).get("documents") if isinstance(wired, dict) else None if docs: - resolvedParams.setdefault("documentList", docs) + resolvedParams.setdefault("documentList", wired) elif inp is not None: resolvedParams.setdefault("input", inp) diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py index ee375d89..bc7a5a64 100644 --- a/modules/workflows/methods/methodAi/actions/generateCode.py +++ b/modules/workflows/methods/methodAi/actions/generateCode.py @@ -21,7 +21,6 @@ async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult: if not prompt.strip(): return ActionResult.isFailure(error="prompt is required") - documentList = parameters.get("documentList", []) # Optional: if omitted, formats determined from prompt by AI resultType = parameters.get("resultType") @@ -34,19 +33,15 @@ async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult: parentOperationId = parameters.get('parentOperationId') try: - # Convert documentList to DocumentReferenceList if needed - docRefList = None - if documentList: - from modules.datamodels.datamodelDocref import DocumentReferenceList - - if isinstance(documentList, DocumentReferenceList): - docRefList = documentList - elif isinstance(documentList, str): - docRefList = DocumentReferenceList.from_string_list([documentList]) - elif isinstance(documentList, list): - docRefList = DocumentReferenceList.from_string_list(documentList) - else: - docRefList = DocumentReferenceList(references=[]) + from modules.datamodels.datamodelDocref import coerceDocumentReferenceList + + raw_dl = parameters.get("documentList") + if raw_dl is None or raw_dl == "": + docRefList = None + else: + docRefList = coerceDocumentReferenceList(raw_dl) + if not docRefList.references: + docRefList = None # Prepare title title = "Generated Code" diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 0edcd141..5a1ff0eb 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -21,7 +21,6 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: if not prompt.strip(): return ActionResult.isFailure(error="prompt is required") - documentList = parameters.get("documentList", []) documentType = parameters.get("documentType") # Prefer explicit outputFormat (flow UI); resultType remains for legacy / API callers. resultType = parameters.get("outputFormat") or parameters.get("resultType") @@ -37,19 +36,16 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: parentOperationId = parameters.get('parentOperationId') try: - # Convert documentList to DocumentReferenceList if needed - docRefList = None - if documentList: - from modules.datamodels.datamodelDocref import DocumentReferenceList - - if isinstance(documentList, DocumentReferenceList): - docRefList = documentList - elif isinstance(documentList, str): - docRefList = DocumentReferenceList.from_string_list([documentList]) - elif isinstance(documentList, list): - docRefList = DocumentReferenceList.from_string_list(documentList) - else: - docRefList = DocumentReferenceList(references=[]) + # Convert documentList to DocumentReferenceList (handles dict {"documents": [...]}, list of ids, str, etc.) + from modules.datamodels.datamodelDocref import coerceDocumentReferenceList + + raw_dl = parameters.get("documentList") + if raw_dl is None or raw_dl == "": + docRefList = None + else: + docRefList = coerceDocumentReferenceList(raw_dl) + if not docRefList.references: + docRefList = None title_raw = parameters.get("title") title = (title_raw.strip() if isinstance(title_raw, str) else "") or None From 9ae2ffc415be8dbed939e9b0cad055335bc26f92 Mon Sep 17 00:00:00 2001 From: Ida Date: Sun, 3 May 2026 18:01:10 +0200 Subject: [PATCH 05/14] ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes --- .../features/graphicalEditor/nodeDefinitions/file.py | 4 ++++ modules/features/graphicalEditor/portTypes.py | 3 +++ .../services/serviceAi/subAiCallLooping.py | 6 ++++++ .../serviceGeneration/renderers/rendererCsv.py | 10 ++++++++++ 4 files changed, 23 insertions(+) diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index ffa4d722..a5390016 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -20,7 +20,11 @@ FILE_NODES = [ ], "inputs": 1, "outputs": 1, +<<<<<<< HEAD "inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload", "LoopItem", "ActionResult"]}}, +======= + "inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload"]}}, +>>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False}, "_method": "file", diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index deab83b9..af0759f5 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -723,9 +723,12 @@ def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]: if not schema or schemaName == "Transit": return result +<<<<<<< HEAD if schemaName == "DocumentList": _coerce_document_list_upload_fields(result) +======= +>>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) # Only default **required** fields. Optional fields stay absent so DataRefs / context # resolution never pick a synthetic `{}` or `[]` (e.g. AiResult.responseData when the # model returned plain text only). diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index d532115d..ed8ddcda 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -559,6 +559,7 @@ class AiCallLooper: if iteration >= maxIterations: logger.warning(f"AI call stopped after maximum iterations ({maxIterations})") +<<<<<<< HEAD # Prefer last repaired complete JSON from getContexts (raw `result` is only the last fragment). if lastValidCompletePart and useCase and not useCase.requiresExtraction: try: @@ -577,6 +578,11 @@ class AiCallLooper: except Exception as e: logger.debug("Max-iterations fallback on completePart failed: %s", e) +======= + # This code path should never be reached because all registered use cases + # return early when JSON is complete. This would only execute for use cases that + # require section extraction, but no such use cases are currently registered. +>>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) logger.error( "End of callAiWithLooping without success for use case %r (iterations=%s, lastResultLen=%s)", useCaseId, diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py index a8b2c346..2c8d35b3 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py @@ -39,6 +39,12 @@ class RendererCsv(BaseRenderer): """ return ["table", "code_block"] +<<<<<<< HEAD +======= +<<<<<<< HEAD + async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: +======= +>>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) async def render( self, extractedContent: Dict[str, Any], @@ -48,6 +54,10 @@ class RendererCsv(BaseRenderer): *, style: Dict[str, Any] = None, ) -> List[RenderedDocument]: +<<<<<<< HEAD +======= +>>>>>>> 0659d0d2 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) +>>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) """Render extracted JSON content to CSV format. Produces one CSV file per table section.""" _ = style try: From 5455e093679ecb2e8131cfa0f248886c4a551e04 Mon Sep 17 00:00:00 2001 From: Ida Date: Mon, 4 May 2026 17:23:56 +0200 Subject: [PATCH 06/14] fix: completely fixed grouping to be like clickup grouping, removed wrong mechanisms --- app.py | 3 + modules/datamodels/datamodelPagination.py | 155 +++++--- modules/interfaces/interfaceDbApp.py | 110 ++++-- modules/interfaces/interfaceDbManagement.py | 40 +- modules/routes/routeBilling.py | 346 ++++++++++++++--- modules/routes/routeDataConnections.py | 122 +++--- modules/routes/routeDataFiles.py | 292 +++++++-------- modules/routes/routeDataMandates.py | 19 +- modules/routes/routeDataPrompts.py | 125 +++++-- modules/routes/routeDataUsers.py | 52 +-- modules/routes/routeHelpers.py | 354 ++++++++++++------ modules/routes/routeTableViews.py | 177 +++++++++ .../serviceAgent/coreTools/_helpers.py | 34 +- .../serviceAgent/coreTools/_workspaceTools.py | 178 +-------- .../services/serviceChat/mainServiceChat.py | 30 +- 15 files changed, 1227 insertions(+), 810 deletions(-) create mode 100644 modules/routes/routeTableViews.py diff --git a/app.py b/app.py index 98e3bd0d..1f8f87f9 100644 --- a/app.py +++ b/app.py @@ -600,6 +600,9 @@ app.include_router(promptRouter) from modules.routes.routeDataConnections import router as connectionsRouter app.include_router(connectionsRouter) +from modules.routes.routeTableViews import router as tableViewsRouter +app.include_router(tableViewsRouter) + from modules.routes.routeSecurityLocal import router as localRouter app.include_router(localRouter) diff --git a/modules/datamodels/datamodelPagination.py b/modules/datamodels/datamodelPagination.py index 7bda7717..10476ccb 100644 --- a/modules/datamodels/datamodelPagination.py +++ b/modules/datamodels/datamodelPagination.py @@ -9,50 +9,95 @@ All models use camelStyle naming convention for consistency with frontend. from typing import List, Dict, Any, Optional, Generic, TypeVar from pydantic import BaseModel, Field, ConfigDict import math +import uuid T = TypeVar('T') # --------------------------------------------------------------------------- -# Table Grouping models +# Group layout models (Strategy B — derived from Views, purely presentational) # --------------------------------------------------------------------------- -class TableGroupNode(BaseModel): +class GroupByLevel(BaseModel): + """One level of a multi-level grouping definition, stored inside a TableListView config.""" + field: str = Field(..., description="Field key to group by") + nullLabel: str = Field(default="—", description="Display label for null/empty values") + direction: str = Field( + default="asc", + description="Order of group bands at this level: 'asc' or 'desc'", + ) + + +class GroupBand(BaseModel): """ - A single node in a user-defined group tree for a FormGeneratorTable. + A contiguous block of rows that share the same group path, intersecting the current page. - Items belong to exactly one group (no multi-membership). - Groups can be nested to arbitrary depth via subGroups. + startRowIndex and rowCount are 0-based indices relative to the current page's items[]. """ - id: str - name: str - itemIds: List[str] = Field(default_factory=list) - subGroups: List['TableGroupNode'] = Field(default_factory=list) - order: int = 0 - isExpanded: bool = True - -TableGroupNode.model_rebuild() + path: List[str] = Field(..., description="Hierarchical group key (one entry per level)") + label: str = Field(..., description="Display label for this band (last path element)") + startRowIndex: int = Field(..., description="0-based start index within items[] on this page") + rowCount: int = Field(..., description="Number of items in this band on this page") -class TableGrouping(BaseModel): +class GroupLayout(BaseModel): """ - Persisted grouping configuration for one (user, contextKey) pair. - Stored in table_groupings in poweron_app (auto-created). + Grouping structure for the current response page. + Included only when the effective view has groupByLevels configured. + The frontend renders group header rows by iterating bands and inserting + headers before each startRowIndex. + """ + levels: List[str] = Field(..., description="Ordered field keys that define the grouping hierarchy") + bands: List[GroupBand] = Field(..., description="Bands intersecting the current page, in order") + + +class AppliedViewMeta(BaseModel): + """Minimal metadata about the view that was applied to this response.""" + viewKey: Optional[str] = None + displayName: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Persisted view model +# --------------------------------------------------------------------------- + +class TableListView(BaseModel): + """ + A saved table view for one (userId, contextKey) pair. + + config schema (schemaVersion=1): + { + "schemaVersion": 1, + "filters": {}, # same structure as PaginationParams.filters + "sort": [], # same structure as PaginationParams.sort + "groupByLevels": [ # ordered grouping levels + {"field": "scope", "nullLabel": "—", "direction": "asc"} + ], + "collapsedSectionKeys": [], # optional: section UI (stable group keys) + "collapsedGroupKeys": [], # optional: inline group bands (path.join('///')) + } contextKey convention: API path without /api/ prefix and without trailing slash. - Examples: "connections", "prompts", "admin/users", "trustee/{instanceId}/documents" + Examples: "connections", "prompts", "admin/users", "files/list" + + viewKey is a user-defined slug, unique per (userId, mandateId, contextKey). """ - id: str + id: str = Field(default_factory=lambda: str(uuid.uuid4())) userId: str + mandateId: Optional[str] = None contextKey: str - rootGroups: List[TableGroupNode] = Field(default_factory=list) + viewKey: str + displayName: str + config: Dict[str, Any] = Field(default_factory=dict) updatedAt: Optional[float] = None +# --------------------------------------------------------------------------- +# Sort and pagination models +# --------------------------------------------------------------------------- + class SortField(BaseModel): - """ - Single sort field configuration. - """ + """Single sort field configuration.""" field: str = Field(..., description="Field name to sort by") direction: str = Field(..., description="Sort direction: 'asc' or 'desc'") @@ -61,16 +106,13 @@ class PaginationParams(BaseModel): """ Complete pagination state including page, sorting, and filters. - Grouping extensions (both optional — omit when not using grouping): - groupId — Scope the request to items belonging to this group. - The backend resolves it to an itemIds IN-filter before - applying normal pagination/search/filter logic. - Also applied for mode=ids and mode=filterValues so that - bulk-select and filter-dropdowns respect the group scope. - saveGroupTree — If present the backend persists this tree for the current - (user, contextKey) pair *before* fetching, then returns - the confirmed tree in the response groupTree field. - Omit on every request that does not change the group tree. + View extension (optional): + viewKey — Slug of a saved TableListView for this (user, contextKey) pair. + The server loads the view, merges its filters/sort/groupByLevels + into the effective query (request fields take priority over view + defaults for explicitly provided fields), and returns groupLayout + in the response when groupByLevels is non-empty. + Omit or set to None for the default (ungrouped) view. """ page: int = Field(ge=1, description="Current page number (1-based)") pageSize: int = Field(ge=1, le=1000, description="Number of items per page") @@ -85,13 +127,16 @@ class PaginationParams(BaseModel): - Supported operators: equals/eq, contains, startsWith, endsWith, gt, gte, lt, lte, in, notIn - Multiple filters are combined with AND logic""" ) - groupId: Optional[str] = Field( + viewKey: Optional[str] = Field( default=None, - description="Scope request to items of this group (resolved server-side to itemIds IN-filter)", + description="Slug of a saved view to load; server merges view config into effective query", ) - saveGroupTree: Optional[List[Dict[str, Any]]] = Field( + groupByLevels: Optional[List[GroupByLevel]] = Field( default=None, - description="If set, persist this group tree before fetching (optimistic save)", + description=( + "When set (including an empty list), replaces the saved view's groupByLevels for this request. " + "Omit entirely to use grouping from the view only." + ), ) @@ -130,16 +175,22 @@ class PaginatedResponse(BaseModel, Generic[T]): """ Response containing paginated data and metadata. - groupTree is included when the endpoint supports table grouping and the - current user has a saved group tree for the requested contextKey. - It is None when grouping is not configured for the endpoint or the user - has not created any groups yet. Frontend must treat None as an empty tree. + groupLayout is included when the effective view has groupByLevels configured. + It describes how to render group header rows in the current page's items[]. + Omitted (None) when no grouping is active. + + appliedView describes which saved view was merged into this response, + allowing the frontend to synchronise its view selector. """ items: List[T] = Field(..., description="Array of items for current page") pagination: Optional[PaginationMetadata] = Field(..., description="Pagination metadata (None if pagination not applied)") - groupTree: Optional[List[TableGroupNode]] = Field( + groupLayout: Optional[GroupLayout] = Field( default=None, - description="Current group tree for this (user, contextKey) pair — None if no grouping configured", + description="Group band structure for this page (None if no grouping active)", + ) + appliedView: Optional[AppliedViewMeta] = Field( + default=None, + description="Metadata about the view applied to this response", ) model_config = ConfigDict(arbitrary_types_allowed=True) @@ -148,34 +199,30 @@ class PaginatedResponse(BaseModel, Generic[T]): def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]: """ Normalize pagination dictionary to handle frontend variations. - Moves top-level "search" field into filters if present. - Grouping fields (groupId, saveGroupTree) are passed through as-is. - Args: - pagination_dict: Raw pagination dictionary from frontend - - Returns: - Normalized pagination dictionary ready for PaginationParams parsing + - Moves top-level "search" field into filters if present. + - Silently drops legacy fields (groupId, saveGroupTree) that were part of the + old tree-grouping implementation so old clients do not cause validation errors. + - Passes viewKey through unchanged. """ if not pagination_dict: return pagination_dict - # Create a copy to avoid modifying the original normalized = dict(pagination_dict) - # Ensure required fields have sensible defaults if "page" not in normalized: normalized["page"] = 1 if "pageSize" not in normalized: normalized["pageSize"] = 25 - # Move top-level "search" into filters if present + # Move top-level "search" into filters if "search" in normalized: if "filters" not in normalized or normalized["filters"] is None: normalized["filters"] = {} normalized["filters"]["search"] = normalized.pop("search") - # groupId / saveGroupTree are valid PaginationParams fields — pass through unchanged. - # No transformation needed; Pydantic will validate them. + # Drop legacy tree-grouping fields — harmless if already absent + normalized.pop("groupId", None) + normalized.pop("saveGroupTree", None) return normalized diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 6f1d9487..ad445e7f 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -4028,58 +4028,92 @@ class AppObjects: raise # ------------------------------------------------------------------------- - # Table Grouping (user-defined groups for FormGeneratorTable instances) + # Table List Views (saved display presets: filters, sort, groupByLevels) # ------------------------------------------------------------------------- - def getTableGrouping(self, contextKey: str): - """ - Load the group tree for the current user and the given contextKey. - - Returns a TableGrouping instance or None if no grouping has been saved yet. - contextKey identifies the table instance, e.g. "connections", "prompts", - "admin/users", "trustee/{instanceId}/documents". - """ - from modules.datamodels.datamodelPagination import TableGrouping + def getTableListViews(self, contextKey: str) -> list: + """Return all saved views for the current user and contextKey.""" + from modules.datamodels.datamodelPagination import TableListView try: - records = self.db.getRecordset( - TableGrouping, + rows = self.db.getRecordset( + TableListView, recordFilter={"userId": str(self.userId), "contextKey": contextKey}, ) - if not records: - return None - row = records[0] - return TableGrouping.model_validate(row) if isinstance(row, dict) else row + result = [] + for row in (rows or []): + try: + result.append(TableListView.model_validate(row) if isinstance(row, dict) else row) + except Exception: + pass + return result except Exception as e: - logger.error(f"getTableGrouping failed for user={self.userId} key={contextKey}: {e}") + logger.error(f"getTableListViews failed for user={self.userId} context={contextKey}: {e}") + return [] + + def getTableListView(self, contextKey: str, viewKey: str): + """Return one view by viewKey or None if not found.""" + from modules.datamodels.datamodelPagination import TableListView + try: + rows = self.db.getRecordset( + TableListView, + recordFilter={"userId": str(self.userId), "contextKey": contextKey, "viewKey": viewKey}, + ) + if not rows: + return None + row = rows[0] + return TableListView.model_validate(row) if isinstance(row, dict) else row + except Exception as e: + logger.error(f"getTableListView failed for user={self.userId} key={viewKey}: {e}") return None - def upsertTableGrouping(self, contextKey: str, rootGroups: list): - """ - Create or replace the group tree for the current user and contextKey. + def createTableListView(self, contextKey: str, viewKey: str, displayName: str, config: dict): + """Create a new view. Raises ValueError if viewKey already exists for this context.""" + from modules.datamodels.datamodelPagination import TableListView + from modules.shared.timeUtils import getUtcTimestamp + if self.getTableListView(contextKey=contextKey, viewKey=viewKey) is not None: + raise ValueError(f"View '{viewKey}' already exists for context '{contextKey}'") + data = { + "id": str(uuid.uuid4()), + "userId": str(self.userId), + "contextKey": contextKey, + "viewKey": viewKey, + "displayName": displayName, + "config": config, + "updatedAt": getUtcTimestamp(), + } + try: + self.db.recordCreate(TableListView, data) + return TableListView.model_validate(data) + except Exception as e: + logger.error(f"createTableListView failed: {e}") + raise - rootGroups is a list of TableGroupNode-compatible dicts (the full tree). - Returns the saved TableGrouping instance. - """ - from modules.datamodels.datamodelPagination import TableGrouping + def updateTableListView(self, viewId: str, updates: dict): + """Update an existing view by its primary key id.""" + from modules.datamodels.datamodelPagination import TableListView from modules.shared.timeUtils import getUtcTimestamp try: - existing = self.getTableGrouping(contextKey) - data = { - "id": existing.id if existing else str(uuid.uuid4()), - "userId": str(self.userId), - "contextKey": contextKey, - "rootGroups": rootGroups, - "updatedAt": getUtcTimestamp(), - } - if existing: - self.db.recordModify(TableGrouping, existing.id, data) - else: - self.db.recordCreate(TableGrouping, data) - return TableGrouping.model_validate(data) + updates = {**updates, "updatedAt": getUtcTimestamp()} + self.db.recordModify(TableListView, viewId, updates) + rows = self.db.getRecordset(TableListView, recordFilter={"id": viewId}) + if rows: + row = rows[0] + return TableListView.model_validate(row) if isinstance(row, dict) else row + return None except Exception as e: - logger.error(f"upsertTableGrouping failed for user={self.userId} key={contextKey}: {e}") + logger.error(f"updateTableListView failed for id={viewId}: {e}") raise + def deleteTableListView(self, viewId: str) -> bool: + """Delete a view by primary key id. Returns True on success.""" + from modules.datamodels.datamodelPagination import TableListView + try: + self.db.recordDelete(TableListView, viewId) + return True + except Exception as e: + logger.error(f"deleteTableListView failed for id={viewId}: {e}") + return False + # Public Methods diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 9c3000e4..e212d502 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -1532,44 +1532,8 @@ class ComponentObjects: raise FileDeletionError(f"Error deleting files in batch: {str(e)}") def _ensureFeatureInstanceGroup(self, featureInstanceId: str, contextKey: str = "files/list") -> Optional[str]: - """Return the groupId of the default group for a feature instance. - Creates the group if it doesn't exist yet.""" - try: - import modules.interfaces.interfaceDbApp as _appIface - appInterface = _appIface.getInterface(self._currentUser) - existing = appInterface.getTableGrouping(contextKey) - nodes = [n.model_dump() if hasattr(n, 'model_dump') else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])] - # Look for group with name matching featureInstanceId - def _find(nds): - for nd in nds: - nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None) - nmeta = nd.get("meta", {}) if isinstance(nd, dict) else getattr(nd, "meta", {}) - if (nmeta or {}).get("featureInstanceId") == featureInstanceId: - return nid - subs = nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []) - result = _find(subs) - if result: - return result - return None - found = _find(nodes) - if found: - return found - # Create new group - import uuid - newId = str(uuid.uuid4()) - newGroup = { - "id": newId, - "name": featureInstanceId, - "itemIds": [], - "subGroups": [], - "meta": {"featureInstanceId": featureInstanceId}, - } - nodes.append(newGroup) - appInterface.upsertTableGrouping(contextKey, nodes) - return newId - except Exception as e: - logger.error(f"_ensureFeatureInstanceGroup failed: {e}") - return None + """Stub — file group tree removed. Returns None.""" + return None def copyFile(self, sourceFileId: str, newFileName: Optional[str] = None) -> FileItem: """Create a full duplicate of a file (FileItem + FileData).""" diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 34ebc184..b7fcdeca 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -9,9 +9,9 @@ Features: - Admin endpoints: Manage settings, add credits, view all accounts """ -from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query, Header +from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query, Header, status +from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional -from fastapi import status import logging from datetime import date, datetime, timezone from pydantic import BaseModel, Field @@ -24,7 +24,13 @@ from modules.interfaces.interfaceDbBilling import getInterface as getBillingInte from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService import json import math -from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict +from modules.datamodels.datamodelPagination import ( + PaginationParams, + PaginatedResponse, + PaginationMetadata, + normalize_pagination_dict, + AppliedViewMeta, +) from modules.datamodels.datamodelBilling import ( BillingAccount, BillingTransaction, @@ -478,50 +484,193 @@ def getBalanceForMandate( raise HTTPException(status_code=500, detail=str(e)) -@router.get("/transactions", response_model=List[TransactionResponse]) +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): + r[k] = v.isoformat() + elif isinstance(v, date_cls): + 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): + ev = r[ek] + r[ek] = getattr(ev, "value", None) or str(ev) + return r + + +def _load_billing_user_transactions_normalized(billingService) -> List[Dict[str, Any]]: + raw = billingService.getTransactionHistory(limit=5000) + return [_normalize_billing_tx_dict(t) for t in raw] + + +def _view_user_transactions_filtered_list( + billing_interface, + load_mandate_ids: Optional[List[str]], + effective_scope: str, + personal_user_id: Optional[str], + pagination_params: PaginationParams, + ctx_user, +) -> List[Dict[str, Any]]: + """Up to 5000 rows: SQL window + in-memory filters/sort (incl. enriched columns).""" + from modules.interfaces.interfaceDbManagement import ComponentObjects + + bulk_params = pagination_params.model_copy(deep=True) + bulk_params.page = 1 + bulk_params.pageSize = 5000 + bulk_result = billing_interface.getTransactionsForMandatesPaginated( + mandateIds=load_mandate_ids, + pagination=bulk_params, + scope=effective_scope, + userId=personal_user_id, + ) + all_items = [_normalize_billing_tx_dict(dict(x)) for x in bulk_result.items] + comp = ComponentObjects() + comp.setUserContext(ctx_user) + if pagination_params.filters: + all_items = comp._applyFilters(all_items, pagination_params.filters) + if pagination_params.sort: + all_items = comp._applySorting(all_items, pagination_params.sort) + return all_items + + +@router.get("/transactions") @limiter.limit("30/minute") def getTransactions( request: Request, limit: int = Query(default=50, ge=1, le=500), offset: int = Query(default=0, ge=0), - ctx: RequestContext = Depends(getRequestContext) + pagination: Optional[str] = Query( + None, + description="JSON PaginationParams for table UI (filters, sort, viewKey, groupByLevels).", + ), + mode: Optional[str] = Query(None, description="'filterValues' | 'ids' with pagination"), + column: Optional[str] = Query(None, description="Column for mode=filterValues"), + ctx: RequestContext = Depends(getRequestContext), ): """ Get transaction history across all mandates the user belongs to. + + Without ``pagination`` query: legacy behaviour — returns a JSON array of + transactions (`limit`/`offset` window). + + With ``pagination`` JSON: returns ``{ items, pagination, groupLayout?, appliedView? }``. + Table list views use contextKey ``billing/transactions``. """ try: billingService = getBillingService( ctx.user, ctx.mandateId, - featureCode="billing" + featureCode="billing", ) - - # Fetch enough transactions for pagination + + if pagination: + from modules.routes.routeHelpers import ( + applyViewToParams, + buildGroupLayout, + effective_group_by_levels, + handleFilterValuesInMemory, + handleIdsInMemory, + resolveView, + ) + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface + from modules.interfaces.interfaceDbManagement import ComponentObjects + + CONTEXT_KEY = "billing/transactions" + + try: + paginationDict = json.loads(pagination) + if not paginationDict: + raise ValueError("empty pagination") + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError, TypeError) as e: + raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") + + appInterface = getAppInterface(ctx.user) + viewKey = paginationParams.viewKey + viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey) + viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None + paginationParams = applyViewToParams(paginationParams, viewConfig) + groupByLevels = effective_group_by_levels(paginationParams, viewConfig) + + all_items = _load_billing_user_transactions_normalized(billingService) + + if mode == "filterValues": + if not column: + raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") + return handleFilterValuesInMemory(all_items, column, pagination) + + if mode == "ids": + return handleIdsInMemory(all_items, pagination) + + comp = ComponentObjects() + comp.setUserContext(ctx.user) + if paginationParams.filters: + all_items = comp._applyFilters(all_items, paginationParams.filters) + if paginationParams.sort: + all_items = comp._applySorting(all_items, paginationParams.sort) + + totalItems = len(all_items) + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + + if not groupByLevels: + pstart = (paginationParams.page - 1) * paginationParams.pageSize + page_items = all_items[pstart : pstart + paginationParams.pageSize] + group_layout = None + else: + page_items, group_layout = buildGroupLayout( + all_items, + groupByLevels, + paginationParams.page, + paginationParams.pageSize, + ) + + resp: Dict[str, Any] = { + "items": page_items, + "pagination": PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters, + ).model_dump(), + } + if group_layout: + resp["groupLayout"] = group_layout.model_dump() + if viewMeta: + resp["appliedView"] = viewMeta.model_dump() + return JSONResponse(content=resp) + transactions = billingService.getTransactionHistory(limit=offset + limit) - - # Convert to response model - result = [] - for t in transactions[offset:offset + limit]: - result.append(TransactionResponse( - id=t.get("id"), - accountId=t.get("accountId"), - transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")), - amount=t.get("amount", 0.0), - description=t.get("description", ""), - referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None, - workflowId=t.get("workflowId"), - featureCode=t.get("featureCode"), - featureInstanceId=t.get("featureInstanceId"), - aicoreProvider=t.get("aicoreProvider"), - aicoreModel=t.get("aicoreModel"), - createdByUserId=t.get("createdByUserId"), - sysCreatedAt=t.get("sysCreatedAt"), - mandateId=t.get("mandateId"), - mandateName=t.get("mandateName") - )) - + result: List[TransactionResponse] = [] + for t in transactions[offset : offset + limit]: + result.append( + TransactionResponse( + id=t.get("id"), + accountId=t.get("accountId"), + transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")), + amount=t.get("amount", 0.0), + description=t.get("description", ""), + referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None, + workflowId=t.get("workflowId"), + featureCode=t.get("featureCode"), + featureInstanceId=t.get("featureInstanceId"), + aicoreProvider=t.get("aicoreProvider"), + aicoreModel=t.get("aicoreModel"), + createdByUserId=t.get("createdByUserId"), + sysCreatedAt=t.get("sysCreatedAt"), + mandateId=t.get("mandateId"), + mandateName=t.get("mandateName"), + ) + ) return result - + + except HTTPException: + raise except Exception as e: logger.error(f"Error getting billing transactions: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -1757,7 +1906,7 @@ def getUserViewStatistics( @router.get("/view/users/transactions", response_model=PaginatedResponse[UserTransactionResponse]) -@limiter.limit("30/minute") +@limiter.limit("120/minute") def getUserViewTransactions( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), @@ -1808,7 +1957,6 @@ def getUserViewTransactions( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from fastapi.responses import JSONResponse crossFilterParams = parseCrossFilterPagination(column, pagination) values = billingInterface.getTransactionDistinctValues( mandateIds=loadMandateIds, @@ -1820,7 +1968,6 @@ def getUserViewTransactions( return JSONResponse(content=values) if mode == "ids": - from fastapi.responses import JSONResponse paginationParams = None if pagination: import json as _json @@ -1835,6 +1982,66 @@ def getUserViewTransactions( ) if hasattr(billingInterface, 'getTransactionIds') else [] return JSONResponse(content=ids) + if mode == "groupSummary": + if not pagination: + raise HTTPException(status_code=400, detail="pagination required for groupSummary") + import json as _json + from collections import defaultdict + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface + from modules.routes.routeHelpers import ( + applyViewToParams, + effective_group_by_levels, + resolveView, + ) + + pagination_dict = _json.loads(pagination) + pagination_dict = normalize_pagination_dict(pagination_dict) + summary_params = PaginationParams(**pagination_dict) + CONTEXT_KEY = "billing/view/users/transactions" + app_interface = getAppInterface(ctx.user) + summary_vk = summary_params.viewKey + summary_view_cfg, _ = resolveView(app_interface, CONTEXT_KEY, summary_vk) + summary_params = applyViewToParams(summary_params, summary_view_cfg) + levels = effective_group_by_levels(summary_params, summary_view_cfg) + if not levels or not levels[0].get("field"): + raise HTTPException( + status_code=400, + detail="groupByLevels[0].field required for groupSummary", + ) + field = levels[0]["field"] + null_label = str(levels[0].get("nullLabel") or "—") + all_rows = _view_user_transactions_filtered_list( + billingInterface, + loadMandateIds, + scope, + personalUserId, + summary_params, + ctx.user, + ) + counts: Dict[str, int] = defaultdict(int) + labels: Dict[str, str] = {} + null_key = "\x00NULL" + for item in all_rows: + raw = item.get(field) + if raw is None or raw == "": + nk = null_key + labels[nk] = null_label + else: + nk = str(raw) + if nk not in labels: + labels[nk] = nk + counts[nk] += 1 + groups_out: List[Dict[str, Any]] = [] + for nk in sorted(counts.keys(), key=lambda x: (x == null_key, labels.get(x, x).lower())): + groups_out.append( + { + "value": None if nk == null_key else nk, + "label": labels.get(nk, nk), + "totalCount": counts[nk], + } + ) + return JSONResponse(content={"groups": groups_out}) + paginationParams = None if pagination: import json as _json @@ -1847,15 +2054,21 @@ def getUserViewTransactions( if not paginationParams: paginationParams = PaginationParams(page=1, pageSize=50) - result = billingInterface.getTransactionsForMandatesPaginated( - mandateIds=loadMandateIds, - pagination=paginationParams, - scope=effectiveScope, - userId=personalUserId, + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface + from modules.routes.routeHelpers import ( + applyViewToParams, + buildGroupLayout, + effective_group_by_levels, + resolveView, ) - logger.debug(f"SQL-paginated {result.totalItems} transactions for user {ctx.user.id} " - f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page})") + CONTEXT_KEY = "billing/view/users/transactions" + appInterface = getAppInterface(ctx.user) + viewKey = paginationParams.viewKey + viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey) + viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None + paginationParams = applyViewToParams(paginationParams, viewConfig) + groupByLevels = effective_group_by_levels(paginationParams, viewConfig) def _toResponse(d): return UserTransactionResponse( @@ -1875,9 +2088,56 @@ def getUserViewTransactions( mandateId=d.get("mandateId"), mandateName=d.get("mandateName"), userId=d.get("userId"), - userName=d.get("userName") + userName=d.get("userName"), ) + if groupByLevels: + all_items = _view_user_transactions_filtered_list( + billingInterface, + loadMandateIds, + effectiveScope, + personalUserId, + paginationParams, + ctx.user, + ) + + totalItems = len(all_items) + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + page_items, group_layout = buildGroupLayout( + all_items, + groupByLevels, + paginationParams.page, + paginationParams.pageSize, + ) + resp: Dict[str, Any] = { + "items": [_toResponse(d).model_dump(mode="json") for d in page_items], + "pagination": PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters, + ).model_dump(mode="json"), + } + if group_layout: + resp["groupLayout"] = group_layout.model_dump(mode="json") + if viewMeta: + resp["appliedView"] = viewMeta.model_dump(mode="json") + return JSONResponse(content=resp) + + result = billingInterface.getTransactionsForMandatesPaginated( + mandateIds=loadMandateIds, + pagination=paginationParams, + scope=effectiveScope, + userId=personalUserId, + ) + + logger.debug( + f"SQL-paginated {result.totalItems} transactions for user {ctx.user.id} " + f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page})" + ) + return PaginatedResponse( items=[_toResponse(d) for d in result.items], pagination=PaginationMetadata( @@ -1887,7 +2147,7 @@ def getUserViewTransactions( totalPages=result.totalPages, sort=paginationParams.sort, filters=paginationParams.filters, - ) + ), ) except Exception as e: diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 124d2fb4..58d36b91 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -17,6 +17,7 @@ import logging import json import math from urllib.parse import quote +from fastapi.responses import JSONResponse from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelSecurity import Token @@ -154,12 +155,12 @@ async def get_connections( """ from modules.routes.routeHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels, - handleGroupingInRequest, applyGroupScopeFilter, + resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) + from modules.datamodels.datamodelPagination import AppliedViewMeta CONTEXT_KEY = "connections" - # Parse pagination params early — needed for grouping in all modes paginationParams = None if pagination: try: @@ -171,7 +172,13 @@ async def get_connections( raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") interface = getInterface(currentUser) - groupCtx = handleGroupingInRequest(paginationParams, interface, CONTEXT_KEY) + + # Resolve view and merge config into params + viewKey = paginationParams.viewKey if paginationParams else None + viewConfig, viewDisplayName = resolveView(interface, CONTEXT_KEY, viewKey) + viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None + paginationParams = applyViewToParams(paginationParams, viewConfig) + groupByLevels = effective_group_by_levels(paginationParams, viewConfig) def _buildEnhancedItems(): connections = interface.getUserConnections(currentUser.id) @@ -200,7 +207,6 @@ async def get_connections( try: items = _buildEnhancedItems() enrichRowsWithFkLabels(items, UserConnection) - items = applyGroupScopeFilter(items, groupCtx.itemIds) return handleFilterValuesInMemory(items, column, pagination) except Exception as e: logger.error(f"Error getting filter values for connections: {str(e)}") @@ -208,19 +214,60 @@ async def get_connections( if mode == "ids": try: - items = applyGroupScopeFilter(_buildEnhancedItems(), groupCtx.itemIds) - return handleIdsInMemory(items, pagination) + return handleIdsInMemory(_buildEnhancedItems(), pagination) except Exception as e: logger.error(f"Error getting IDs for connections: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) - try: - # NOTE: Cannot use db.getRecordsetPaginated() here because each connection - # is enriched with computed tokenStatus/tokenExpiresAt (requires per-row DB lookup). - # Token refresh also may trigger re-fetch. Connections per user are typically < 10, - # so in-memory pagination is acceptable. + if mode == "groupSummary": + if not pagination: + raise HTTPException(status_code=400, detail="pagination required for groupSummary") + from modules.routes.routeHelpers import ( + apply_strategy_b_filters_and_sort, + build_group_summary_groups, + ) + if not groupByLevels or not groupByLevels[0].get("field"): + raise HTTPException( + status_code=400, + detail="groupByLevels[0].field required for groupSummary", + ) + field = groupByLevels[0]["field"] + null_label = str(groupByLevels[0].get("nullLabel") or "—") + connections = interface.getUserConnections(currentUser.id) + try: + refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id) + if refresh_result.get("refreshed", 0) > 0: + logger.info( + "Silently refreshed %s tokens for user %s (groupSummary)", + refresh_result["refreshed"], + currentUser.id, + ) + connections = interface.getUserConnections(currentUser.id) + except Exception as e: + logger.warning(f"Silent token refresh failed for user {currentUser.id}: {str(e)}") + enhanced_connections_dict = [] + for connection in connections: + tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id) + enhanced_connections_dict.append({ + "id": connection.id, + "userId": connection.userId, + "authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority), + "externalId": connection.externalId, + "externalUsername": connection.externalUsername or "", + "externalEmail": connection.externalEmail, + "status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status), + "connectedAt": connection.connectedAt, + "lastChecked": connection.lastChecked, + "expiresAt": connection.expiresAt, + "tokenStatus": tokenStatus, + "tokenExpiresAt": tokenExpiresAt + }) + enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection) + filtered = apply_strategy_b_filters_and_sort(enhanced_connections_dict, paginationParams, currentUser) + groups_out = build_group_summary_groups(filtered, field, null_label) + return JSONResponse(content={"groups": groups_out}) - # SECURITY FIX: All users (including admins) can only see their own connections + try: connections = interface.getUserConnections(currentUser.id) # Perform silent token refresh for expired OAuth connections @@ -235,7 +282,7 @@ async def get_connections( enhanced_connections_dict = [] for connection in connections: tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id) - connection_dict = { + enhanced_connections_dict.append({ "id": connection.id, "userId": connection.userId, "authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority), @@ -248,46 +295,31 @@ async def get_connections( "expiresAt": connection.expiresAt, "tokenStatus": tokenStatus, "tokenExpiresAt": tokenExpiresAt - } - enhanced_connections_dict.append(connection_dict) + }) enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection) - enhanced_connections_dict = applyGroupScopeFilter(enhanced_connections_dict, groupCtx.itemIds) if paginationParams is None: - return { - "items": enhanced_connections_dict, - "pagination": None, - "groupTree": groupCtx.groupTree, - } + return {"items": enhanced_connections_dict, "pagination": None} - # Apply filtering if provided + # Apply filtering and sorting over full list (Strategy B) + component_interface = ComponentObjects() + component_interface.setUserContext(currentUser) if paginationParams.filters: - component_interface = ComponentObjects() - component_interface.setUserContext(currentUser) - enhanced_connections_dict = component_interface._applyFilters( - enhanced_connections_dict, - paginationParams.filters - ) - - # Apply sorting if provided + enhanced_connections_dict = component_interface._applyFilters(enhanced_connections_dict, paginationParams.filters) if paginationParams.sort: - component_interface = ComponentObjects() - component_interface.setUserContext(currentUser) - enhanced_connections_dict = component_interface._applySorting( - enhanced_connections_dict, - paginationParams.sort - ) + enhanced_connections_dict = component_interface._applySorting(enhanced_connections_dict, paginationParams.sort) totalItems = len(enhanced_connections_dict) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 - startIdx = (paginationParams.page - 1) * paginationParams.pageSize - endIdx = startIdx + paginationParams.pageSize - paged_connections = enhanced_connections_dict[startIdx:endIdx] + # Strategy B grouping: operates on full filtered+sorted list, then slices + page_items, groupLayout = buildGroupLayout( + enhanced_connections_dict, groupByLevels, paginationParams.page, paginationParams.pageSize + ) - return { - "items": paged_connections, + response: dict = { + "items": page_items, "pagination": PaginationMetadata( currentPage=paginationParams.page, pageSize=paginationParams.pageSize, @@ -296,9 +328,13 @@ async def get_connections( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), - "groupTree": groupCtx.groupTree, } - + if groupLayout: + response["groupLayout"] = groupLayout.model_dump() + if viewMeta: + response["appliedView"] = viewMeta.model_dump() + return response + except HTTPException: raise except Exception as e: diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 3394b5c5..244b77b0 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -5,6 +5,7 @@ from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional import logging import json +import math # Import auth module from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext @@ -500,9 +501,10 @@ def get_files( from modules.routes.routeHelpers import ( handleIdsMode, handleFilterValuesInMemory, - handleGroupingInRequest, applyGroupScopeFilter, + resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) import modules.interfaces.interfaceDbApp as _appIface + from modules.datamodels.datamodelPagination import AppliedViewMeta managementInterface = interfaceDbManagement.getInterface( currentUser, @@ -510,11 +512,40 @@ def get_files( featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None ) appInterface = _appIface.getInterface(currentUser) - groupCtx = handleGroupingInRequest(paginationParams, appInterface, "files/list") + + # Resolve view and merge config into params + viewKey = paginationParams.viewKey if paginationParams else None + viewConfig, viewDisplayName = resolveView(appInterface, "files/list", viewKey) + viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None + paginationParams = applyViewToParams(paginationParams, viewConfig) + groupByLevels = effective_group_by_levels(paginationParams, viewConfig) def _filesToDicts(fileItems): return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in fileItems] + if mode == "groupSummary": + if not pagination: + raise HTTPException(status_code=400, detail="pagination required for groupSummary") + from modules.routes.routeHelpers import ( + apply_strategy_b_filters_and_sort, + build_group_summary_groups, + ) + if not groupByLevels or not groupByLevels[0].get("field"): + raise HTTPException( + status_code=400, + detail="groupByLevels[0].field required for groupSummary", + ) + field = groupByLevels[0]["field"] + null_label = str(groupByLevels[0].get("nullLabel") or "—") + allFiles = managementInterface.getAllFiles() + allItems = enrichRowsWithFkLabels( + _filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])), + FileItem, + ) + filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser) + groups_out = build_group_summary_groups(filtered, field, null_label) + return JSONResponse(content={"groups": groups_out}) + if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") @@ -522,33 +553,72 @@ def get_files( items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else []) itemDicts = _filesToDicts(items) enrichRowsWithFkLabels(itemDicts, FileItem) - itemDicts = applyGroupScopeFilter(itemDicts, groupCtx.itemIds) return handleFilterValuesInMemory(itemDicts, column, pagination) if mode == "ids": recordFilter = {"sysCreatedBy": managementInterface.userId} return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter) - result = managementInterface.getAllFiles(pagination=paginationParams) + if not groupByLevels: + # 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) + resp: dict = { + "items": enriched, + "pagination": PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=result.totalItems, + totalPages=result.totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ).model_dump(), + } + else: + items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result]) + resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem), "pagination": None} + if viewMeta: + resp["appliedView"] = viewMeta.model_dump() + return resp - if paginationParams: - enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem), groupCtx.itemIds) - return { - "items": enriched, - "pagination": PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort, - filters=paginationParams.filters - ).model_dump(), - "groupTree": groupCtx.groupTree, - } - else: - items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result]) - enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(items), FileItem), groupCtx.itemIds) - return {"items": enriched, "pagination": None, "groupTree": groupCtx.groupTree} + # Strategy B grouping: load full list, group, then slice + allFiles = managementInterface.getAllFiles() + allItems = enrichRowsWithFkLabels( + _filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])), + FileItem, + ) + + from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort + if paginationParams.filters or paginationParams.sort: + allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser) + + if not paginationParams: + resp = {"items": allItems, "pagination": None} + if viewMeta: + resp["appliedView"] = viewMeta.model_dump() + return resp + + totalItems = len(allItems) + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + page_items, groupLayout = buildGroupLayout(allItems, groupByLevels, paginationParams.page, paginationParams.pageSize) + + resp = { + "items": page_items, + "pagination": PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ).model_dump(), + } + if groupLayout: + resp["groupLayout"] = groupLayout.model_dump() + if viewMeta: + resp["appliedView"] = viewMeta.model_dump() + return resp except HTTPException: raise except Exception as e: @@ -559,34 +629,11 @@ def get_files( ) -def _addFileToGroup(appInterface, fileId: str, groupId: str, contextKey: str = "files/list"): - """Add a file to a group in the persisted groupTree (upsert).""" - from modules.routes.routeHelpers import _collectItemIds - try: - existing = appInterface.getTableGrouping(contextKey) - if not existing: - return - nodes = [n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups] - def _add(nds): - for nd in nds: - nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None) - if nid == groupId: - itemIds = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", [])) - if fileId not in itemIds: - itemIds.append(fileId) - if isinstance(nd, dict): - nd["itemIds"] = itemIds - else: - nd.itemIds = itemIds - return True - subs = nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []) - if _add(subs): - return True - return False - _add(nodes) - appInterface.upsertTableGrouping(contextKey, nodes) - except Exception as e: - logger.warning(f"_addFileToGroup failed: {e}") +def _LEGACY_addFileToGroup_REMOVED(): + """Removed — file-group tree no longer exists. Use multi-select bulk operations.""" + pass + + @router.post("/upload", status_code=status.HTTP_201_CREATED) @@ -596,7 +643,6 @@ async def upload_file( file: UploadFile = File(...), workflowId: Optional[str] = Form(None), featureInstanceId: Optional[str] = Form(None), - groupId: Optional[str] = Form(None), currentUser: User = Depends(getCurrentUser), context: RequestContext = Depends(getRequestContext), ) -> JSONResponse: @@ -630,12 +676,6 @@ async def upload_file( managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId}) fileItem.featureInstanceId = featureInstanceId - # Add to group if groupId was provided - if groupId: - import modules.interfaces.interfaceDbApp as _appIface - appInterface = _appIface.getInterface(currentUser) - _addFileToGroup(appInterface, fileItem.id, groupId) - # Determine response message based on duplicate type if duplicateType == "exact_duplicate": message = f"File '{file.filename}' already exists with identical content. Reusing existing file." @@ -843,82 +883,68 @@ def batchDownload( raise HTTPException(status_code=500, detail=str(e)) -# ── Group bulk endpoints ────────────────────────────────────────────────────── +# ── Bulk file operations (replace former group-based bulk routes) ───────────── -def _get_group_item_ids(contextKey: str, groupId: str, appInterface) -> set: - """Collect all file IDs in a group and its sub-groups from the stored groupTree.""" - from modules.routes.routeHelpers import _collectItemIds - try: - existing = appInterface.getTableGrouping(contextKey) - if not existing: - return set() - nodes = [n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups] - result = _collectItemIds(nodes, groupId) - return result or set() - except Exception as e: - logger.error(f"_get_group_item_ids failed for groupId={groupId}: {e}") - return set() - - -@router.patch("/groups/{groupId}/scope") -@limiter.limit("60/minute") -def patch_group_scope( +@router.post("/bulk/scope") +@limiter.limit("30/minute") +def bulk_set_scope( request: Request, - groupId: str = Path(..., description="Group ID"), body: dict = Body(...), currentUser: User = Depends(getCurrentUser), context: RequestContext = Depends(getRequestContext), ): - """Set scope for all files in a group (recursive).""" - scope = body.get("scope") - if not scope: - raise HTTPException(status_code=400, detail="scope is required") + """Set scope for a list of files by their IDs.""" + fileIds: list = body.get("fileIds") or [] + scope: str = body.get("scope") or "" + if not fileIds: + raise HTTPException(status_code=400, detail="fileIds is required") + validScopes = {"personal", "featureInstance", "mandate", "global"} + if scope not in validScopes: + raise HTTPException(status_code=400, detail=f"Invalid scope. Must be one of {validScopes}") + if scope == "global" and not context.isSysAdmin: + raise HTTPException(status_code=403, detail="Only sysadmins can set global scope") try: - import modules.interfaces.interfaceDbApp as _appIface managementInterface = interfaceDbManagement.getInterface( currentUser, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, ) - appInterface = _appIface.getInterface(currentUser) - fileIds = _get_group_item_ids("files/list", groupId, appInterface) updated = 0 for fid in fileIds: try: managementInterface.updateFile(fid, {"scope": scope}) updated += 1 except Exception as e: - logger.error(f"patch_group_scope: failed to update file {fid}: {e}") - return {"groupId": groupId, "scope": scope, "filesUpdated": updated} + logger.error(f"bulk_set_scope: failed for file {fid}: {e}") + return {"scope": scope, "filesUpdated": updated} except HTTPException: raise except Exception as e: - logger.error(f"patch_group_scope error: {e}") + logger.error(f"bulk_set_scope error: {e}") raise HTTPException(status_code=500, detail=str(e)) -@router.patch("/groups/{groupId}/neutralize") -@limiter.limit("60/minute") -def patch_group_neutralize( +@router.post("/bulk/neutralize") +@limiter.limit("30/minute") +def bulk_set_neutralize( request: Request, - groupId: str = Path(..., description="Group ID"), body: dict = Body(...), currentUser: User = Depends(getCurrentUser), context: RequestContext = Depends(getRequestContext), ): - """Toggle neutralize for all files in a group (recursive, incl. knowledge purge/reindex).""" + """Set neutralize flag for a list of files by their IDs (incl. knowledge purge/reindex).""" + fileIds: list = body.get("fileIds") or [] neutralize = body.get("neutralize") + if not fileIds: + raise HTTPException(status_code=400, detail="fileIds is required") if neutralize is None: raise HTTPException(status_code=400, detail="neutralize is required") try: - import modules.interfaces.interfaceDbApp as _appIface managementInterface = interfaceDbManagement.getInterface( currentUser, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, ) - appInterface = _appIface.getInterface(currentUser) - fileIds = _get_group_item_ids("files/list", groupId, appInterface) updated = 0 for fid in fileIds: try: @@ -929,39 +955,37 @@ def patch_group_neutralize( kIface = interfaceDbKnowledge.getInterface(currentUser) kIface.purgeFileKnowledge(fid) except Exception as ke: - logger.warning(f"patch_group_neutralize: knowledge purge failed for {fid}: {ke}") + logger.warning(f"bulk_set_neutralize: knowledge purge failed for {fid}: {ke}") updated += 1 except Exception as e: - logger.error(f"patch_group_neutralize: failed for file {fid}: {e}") - return {"groupId": groupId, "neutralize": neutralize, "filesUpdated": updated} + logger.error(f"bulk_set_neutralize: failed for file {fid}: {e}") + return {"neutralize": neutralize, "filesUpdated": updated} except HTTPException: raise except Exception as e: - logger.error(f"patch_group_neutralize error: {e}") + logger.error(f"bulk_set_neutralize error: {e}") raise HTTPException(status_code=500, detail=str(e)) -@router.get("/groups/{groupId}/download") -@limiter.limit("20/minute") -async def download_group_zip( +@router.post("/bulk/download-zip") +@limiter.limit("10/minute") +async def bulk_download_zip( request: Request, - groupId: str = Path(..., description="Group ID"), + body: dict = Body(...), currentUser: User = Depends(getCurrentUser), context: RequestContext = Depends(getRequestContext), ): - """Download all files in a group as a ZIP archive.""" + """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") try: - import modules.interfaces.interfaceDbApp as _appIface managementInterface = interfaceDbManagement.getInterface( currentUser, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, ) - appInterface = _appIface.getInterface(currentUser) - fileIds = _get_group_item_ids("files/list", groupId, appInterface) - if not fileIds: - raise HTTPException(status_code=404, detail="Group not found or empty") buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for fid in fileIds: @@ -969,63 +993,21 @@ async def download_group_zip( fileMeta = managementInterface.getFile(fid) fileData = managementInterface.getFileData(fid) if fileMeta and fileData: - name = (fileMeta.get("fileName") if isinstance(fileMeta, dict) else getattr(fileMeta, "fileName", fid)) or fid + name = (getattr(fileMeta, "fileName", None) or fid) zf.writestr(name, fileData) except Exception as fe: - logger.warning(f"download_group_zip: skipping file {fid}: {fe}") + logger.warning(f"bulk_download_zip: skipping file {fid}: {fe}") buf.seek(0) from fastapi.responses import StreamingResponse return StreamingResponse( buf, media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="group-{groupId}.zip"'}, + headers={"Content-Disposition": 'attachment; filename="files.zip"'}, ) except HTTPException: raise except Exception as e: - logger.error(f"download_group_zip error: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/groups/{groupId}") -@limiter.limit("30/minute") -def delete_group( - request: Request, - groupId: str = Path(..., description="Group ID"), - deleteItems: bool = Query(False, description="If true, also delete all files in the group"), - currentUser: User = Depends(getCurrentUser), - context: RequestContext = Depends(getRequestContext), -): - """Remove a group from the groupTree. Optionally delete all its files.""" - try: - import modules.interfaces.interfaceDbApp as _appIface - appInterface = _appIface.getInterface(currentUser) - fileIds = _get_group_item_ids("files/list", groupId, appInterface) - # Remove group from tree - existing = appInterface.getTableGrouping("files/list") - if existing: - from modules.routes.routeHelpers import _removeGroupFromTree - newRoots = _removeGroupFromTree([n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups], groupId) - appInterface.upsertTableGrouping("files/list", newRoots) - # Optionally delete files - deletedFiles = 0 - if deleteItems: - managementInterface = interfaceDbManagement.getInterface( - currentUser, - mandateId=str(context.mandateId) if context.mandateId else None, - featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None, - ) - for fid in fileIds: - try: - managementInterface.deleteFile(fid) - deletedFiles += 1 - except Exception as e: - logger.error(f"delete_group: failed to delete file {fid}: {e}") - return {"groupId": groupId, "deletedFiles": deletedFiles} - except HTTPException: - raise - except Exception as e: - logger.error(f"delete_group error: {e}") + logger.error(f"bulk_download_zip error: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 47eaee02..2c9885ef 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -131,11 +131,9 @@ def get_mandates( handleFilterValuesInMemory, handleIdsInMemory, handleFilterValuesMode, handleIdsMode, parseCrossFilterPagination, - handleGroupingInRequest, applyGroupScopeFilter, ) appInterface = interfaceDbApp.getRootInterface() - groupCtx = handleGroupingInRequest(paginationParams, appInterface, "mandates") def _mandateItemsForAdmin(): items = [] @@ -154,23 +152,18 @@ def get_mandates( values = appInterface.db.getDistinctColumnValues(Mandate, column, crossPagination) return JSONResponse(content=sorted(values, key=lambda v: str(v).lower())) else: - mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds) - return handleFilterValuesInMemory(mandateItems, column, pagination) + return handleFilterValuesInMemory(_mandateItemsForAdmin(), column, pagination) if mode == "ids": if isPlatformAdmin: return handleIdsMode(appInterface.db, Mandate, pagination) else: - mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds) - return handleIdsInMemory(mandateItems, pagination) + return handleIdsInMemory(_mandateItemsForAdmin(), pagination) if isPlatformAdmin: result = appInterface.getAllMandates(pagination=paginationParams) items = result.items if hasattr(result, 'items') else (result if isinstance(result, list) else []) - items = applyGroupScopeFilter( - [i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items], - groupCtx.itemIds, - ) + items = [i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items] if paginationParams and hasattr(result, 'items'): return PaginatedResponse( items=items, @@ -182,13 +175,11 @@ def get_mandates( sort=paginationParams.sort, filters=paginationParams.filters ), - groupTree=groupCtx.groupTree, ) else: - return PaginatedResponse(items=items, pagination=None, groupTree=groupCtx.groupTree) + return PaginatedResponse(items=items, pagination=None) else: - mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds) - return PaginatedResponse(items=mandateItems, pagination=None, groupTree=groupCtx.groupTree) + return PaginatedResponse(items=_mandateItemsForAdmin(), pagination=None) except HTTPException: raise diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index 84559ebb..c410d26a 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -3,8 +3,10 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query from typing import List, Dict, Any, Optional from fastapi import status +from fastapi.responses import JSONResponse import logging import json +import math # Import auth module from modules.auth import limiter, getCurrentUser @@ -46,13 +48,13 @@ def get_prompts( """ from modules.routes.routeHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels, - handleGroupingInRequest, applyGroupScopeFilter, + resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) from modules.interfaces.interfaceDbApp import getInterface as getAppInterface + from modules.datamodels.datamodelPagination import AppliedViewMeta CONTEXT_KEY = "prompts" - # Parse pagination params early — needed for grouping in all modes paginationParams = None if pagination: try: @@ -64,7 +66,13 @@ def get_prompts( raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") appInterface = getAppInterface(currentUser) - groupCtx = handleGroupingInRequest(paginationParams, appInterface, CONTEXT_KEY) + + # Resolve view and merge config into params + viewKey = paginationParams.viewKey if paginationParams else None + viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey) + viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None + paginationParams = applyViewToParams(paginationParams, viewConfig) + groupByLevels = effective_group_by_levels(paginationParams, viewConfig) 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] @@ -73,43 +81,98 @@ def get_prompts( managementInterface = interfaceDbManagement.getInterface(currentUser) + if mode == "groupSummary": + if not pagination: + raise HTTPException(status_code=400, detail="pagination required for groupSummary") + from modules.routes.routeHelpers import ( + apply_strategy_b_filters_and_sort, + build_group_summary_groups, + ) + if not groupByLevels or not groupByLevels[0].get("field"): + raise HTTPException( + status_code=400, + detail="groupByLevels[0].field required for groupSummary", + ) + field = groupByLevels[0]["field"] + null_label = str(groupByLevels[0].get("nullLabel") or "—") + result = managementInterface.getAllPrompts(pagination=None) + allItems = _promptsToEnrichedDicts( + result if isinstance(result, list) else (result.items if hasattr(result, "items") else []) + ) + filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser) + groups_out = build_group_summary_groups(filtered, field, null_label) + return JSONResponse(content={"groups": groups_out}) + if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") result = managementInterface.getAllPrompts(pagination=None) - items = _promptsToEnrichedDicts(result) - items = applyGroupScopeFilter(items, groupCtx.itemIds) - return handleFilterValuesInMemory(items, column, pagination) + return handleFilterValuesInMemory(_promptsToEnrichedDicts(result), column, pagination) if mode == "ids": result = managementInterface.getAllPrompts(pagination=None) - items = _promptsToEnrichedDicts(result) - items = applyGroupScopeFilter(items, groupCtx.itemIds) - return handleIdsInMemory(items, pagination) + return handleIdsInMemory(_promptsToEnrichedDicts(result), pagination) - result = managementInterface.getAllPrompts(pagination=paginationParams) + if not groupByLevels: + # No grouping: let DB handle pagination directly + result = managementInterface.getAllPrompts(pagination=paginationParams) + if paginationParams and hasattr(result, 'items'): + response: dict = { + "items": _promptsToEnrichedDicts(result.items), + "pagination": PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=result.totalItems, + totalPages=result.totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ).model_dump(), + } + else: + response = {"items": _promptsToEnrichedDicts(result if isinstance(result, list) else [result]), "pagination": None} + if viewMeta: + response["appliedView"] = viewMeta.model_dump() + return response - if paginationParams: - items = applyGroupScopeFilter(_promptsToEnrichedDicts(result.items), groupCtx.itemIds) - return { - "items": items, - "pagination": PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort, - filters=paginationParams.filters - ).model_dump(), - "groupTree": groupCtx.groupTree, - } - else: - items = applyGroupScopeFilter(_promptsToEnrichedDicts(result), groupCtx.itemIds) - return { - "items": items, - "pagination": None, - "groupTree": groupCtx.groupTree, - } + # Strategy B grouping: load all, filter+sort in-memory, group, then slice + result = managementInterface.getAllPrompts(pagination=None) + allItems = _promptsToEnrichedDicts(result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])) + + if not paginationParams: + response = {"items": allItems, "pagination": None} + if viewMeta: + response["appliedView"] = viewMeta.model_dump() + return response + + if paginationParams.filters or paginationParams.sort: + from modules.interfaces.interfaceDbManagement import ComponentObjects + comp = ComponentObjects() + comp.setUserContext(currentUser) + if paginationParams.filters: + allItems = comp._applyFilters(allItems, paginationParams.filters) + if paginationParams.sort: + allItems = comp._applySorting(allItems, paginationParams.sort) + + totalItems = len(allItems) + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + page_items, groupLayout = buildGroupLayout(allItems, groupByLevels, paginationParams.page, paginationParams.pageSize) + + response = { + "items": page_items, + "pagination": PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ).model_dump(), + } + if groupLayout: + response["groupLayout"] = groupLayout.model_dump() + if viewMeta: + response["appliedView"] = viewMeta.model_dump() + return response @router.post("", response_model=Prompt) diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 25d20c39..671a8ca2 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -208,7 +208,6 @@ def get_users( - GET /api/users/ (no pagination - returns all users in mandate) - GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]} """ - # Parse pagination early — needed for grouping in all modes _paginationParams = None if pagination: try: @@ -219,10 +218,6 @@ def get_users( except (json.JSONDecodeError, ValueError) as e: raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") - from modules.routes.routeHelpers import handleGroupingInRequest as _handleGrouping, applyGroupScopeFilter as _applyGroupScope - _appInterfaceForGrouping = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId) - _groupCtx = _handleGrouping(_paginationParams, _appInterfaceForGrouping, "users") - if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") @@ -233,14 +228,12 @@ def get_users( try: paginationParams = _paginationParams - appInterface = _appInterfaceForGrouping - - if context.mandateId: - # Get users for specific mandate using getUsersByMandate - result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams) + appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId) + if context.mandateId: + result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams) if paginationParams and hasattr(result, 'items'): - enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds) + enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User) return { "items": enriched, "pagination": PaginationMetadata( @@ -251,18 +244,14 @@ def get_users( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), - "groupTree": _groupCtx.groupTree, } else: users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else [] - enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds) - return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree} + return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None} elif context.isPlatformAdmin: - # PlatformAdmin without mandateId — DB-level pagination via interface result = appInterface.getAllUsers(paginationParams) - if paginationParams and hasattr(result, 'items'): - enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds) + enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User) return { "items": enriched, "pagination": PaginationMetadata( @@ -273,18 +262,13 @@ def get_users( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), - "groupTree": _groupCtx.groupTree, } else: users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else []) - enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds) - return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree} + return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None} else: - # Non-SysAdmin without mandateId: aggregate users across all admin mandates rootInterface = getRootInterface() userMandates = rootInterface.getUserMandates(str(context.user.id)) - - # Find mandates where user has admin role adminMandateIds = [] for um in userMandates: umId = getattr(um, 'id', None) @@ -297,13 +281,10 @@ def get_users( if role and role.roleLabel == "admin" and not role.featureInstanceId: adminMandateIds.append(str(mandateId)) break - + if not adminMandateIds: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("No admin access to any mandate") - ) - + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("No admin access to any mandate")) + from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds}) uniqueUserIds = list({ @@ -312,13 +293,10 @@ def get_users( if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)) }) batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {} - allUsers = [ - u.model_dump() if hasattr(u, 'model_dump') else vars(u) - for u in batchUsers.values() - ] - + 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 = _applyGroupScope(_applyFiltersAndSortHelper(allUsers, paginationParams), _groupCtx.itemIds) + filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams) enriched = enrichRowsWithFkLabels(filteredUsers, User) if paginationParams: @@ -327,7 +305,6 @@ def get_users( totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize - return { "items": enriched[startIdx:endIdx], "pagination": PaginationMetadata( @@ -338,10 +315,9 @@ def get_users( sort=paginationParams.sort, filters=paginationParams.filters ).model_dump(), - "groupTree": _groupCtx.groupTree, } else: - return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree} + return {"items": enriched, "pagination": None} except HTTPException: raise except Exception as e: diff --git a/modules/routes/routeHelpers.py b/modules/routes/routeHelpers.py index 9e8644ca..f5af7d06 100644 --- a/modules/routes/routeHelpers.py +++ b/modules/routes/routeHelpers.py @@ -704,154 +704,260 @@ def paginateInMemory( # --------------------------------------------------------------------------- -# Table Grouping helpers +# View resolution and Strategy B grouping engine # --------------------------------------------------------------------------- -from dataclasses import dataclass, field as dc_field - - -@dataclass -class GroupingContext: +def resolveView(interface, contextKey: str, viewKey: Optional[str]): """ - Result of handleGroupingInRequest. - Carries the group tree for the response and the resolved item-ID set for - group-scope filtering (None = no active group scope). + 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). """ - groupTree: Optional[list] # List[TableGroupNode] serialised as dicts — for response - itemIds: Optional[set] # Set[str] when groupId was set, else None + 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 _collectItemIds(nodes: list, groupId: str) -> Optional[set]: +def effective_group_by_levels( + pagination_params: Optional["PaginationParams"], + view_config: Optional[dict], +) -> List[Dict[str, Any]]: """ - Recursively search *nodes* for a node whose id == groupId and collect - all itemIds from it and all its descendant subGroups. - Returns None if the group is not found. + 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. """ - for node in nodes: - nodeId = node.get("id") if isinstance(node, dict) else getattr(node, "id", None) - if nodeId == groupId: - ids: set = set() - _collectAllIds(node, ids) - return ids - subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", []) - result = _collectItemIds(subGroups, groupId) - if result is not None: - return result - return None + 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 _collectAllIds(node, ids: set) -> None: - """Collect itemIds from a node and all its descendants into ids.""" - nodeItemIds = node.get("itemIds", []) if isinstance(node, dict) else getattr(node, "itemIds", []) - for iid in nodeItemIds: - ids.add(str(iid)) - subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", []) - for child in subGroups: - _collectAllIds(child, ids) - - -def _removeGroupFromTree(nodes: list, groupId: str) -> list: - """Remove a group node (and all descendants) from the tree by id.""" - result = [] - for node in nodes: - nodeId = node.get("id") if isinstance(node, dict) else getattr(node, "id", None) - if nodeId == groupId: - continue # skip this node (remove it) - subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", []) - filtered_sub = _removeGroupFromTree(subGroups, groupId) - if isinstance(node, dict): - node = {**node, "subGroups": filtered_sub} - result.append(node) - return result - - -def handleGroupingInRequest( - paginationParams: Optional[PaginationParams], - interface, - contextKey: str, -) -> GroupingContext: +def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional[dict]) -> Optional["PaginationParams"]: """ - Central grouping handler — call at the start of every list route that - supports table grouping. + Merge a view's saved configuration into PaginationParams. - Steps (in order): - 1. If paginationParams.saveGroupTree is set: - persist the new tree via interface.upsertTableGrouping, then clear - saveGroupTree from paginationParams so it is not treated as a filter. - 2. Load the current group tree from the DB (used in step 3 and response). - 3. If paginationParams.groupId is set: - resolve it to a Set[str] of itemIds (including all sub-groups), - then clear groupId from paginationParams so it is not treated as a - normal filter field. - 4. Return a GroupingContext with groupTree (for the response) and itemIds - (for applyGroupScopeFilter). + 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) - The caller does NOT need to handle any grouping logic itself — just call - applyGroupScopeFilter(items, groupCtx.itemIds) and embed groupCtx.groupTree - in the response 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 TableGroupNode + from modules.datamodels.datamodelPagination import PaginationParams, SortField + if not viewConfig: + return params - groupTree = None - itemIds = None + if params is None: + params = PaginationParams(page=1, pageSize=25) - if paginationParams is None: + # Sort: request wins if non-empty + if not params.sort and viewConfig.get("sort"): try: - existing = interface.getTableGrouping(contextKey) - if existing: - groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups] + params.sort = [ + SortField(**s) if isinstance(s, dict) else s + for s in viewConfig["sort"] + ] except Exception as e: - logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}") - return GroupingContext(groupTree=groupTree, itemIds=None) + logger.warning(f"applyViewToParams: could not parse view sort: {e}") - # Step 1: persist saveGroupTree if present - if paginationParams.saveGroupTree is not None: - try: - saved = interface.upsertTableGrouping(contextKey, paginationParams.saveGroupTree) - groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in saved.rootGroups] - except Exception as e: - logger.error(f"handleGroupingInRequest: upsertTableGrouping failed: {e}") - paginationParams.saveGroupTree = None + # 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 - # Step 2: load current tree (only if not already set from save above) - if groupTree is None: - try: - existing = interface.getTableGrouping(contextKey) - if existing: - groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups] - except Exception as e: - logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}") + return params - # Step 3: resolve groupId to itemIds set - if paginationParams.groupId is not None: - targetGroupId = paginationParams.groupId - paginationParams.groupId = None # remove so it is not treated as a normal filter - if groupTree: - itemIds = _collectItemIds(groupTree, targetGroupId) - if itemIds is None: - logger.warning( - f"handleGroupingInRequest: groupId={targetGroupId!r} not found in tree " - f"for contextKey={contextKey!r} — returning empty set" - ) - itemIds = set() # unknown group → show nothing rather than everything + +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 = "—", +) -> List[Dict[str, Any]]: + """ + Build {"value", "label", "totalCount"} for mode=groupSummary (single grouping level). + """ + from collections import defaultdict + + counts: Dict[str, int] = defaultdict(int) + display_by_key: Dict[str, str] = {} + null_key = "\x00NULL" + label_attr = f"{field}Label" + + for item in items: + raw = item.get(field) + if raw is None or raw == "": + nk = null_key + display = null_label else: - # groupId sent but no tree saved yet → return empty (nothing belongs to any group) - logger.warning( - f"handleGroupingInRequest: groupId={targetGroupId!r} set but no tree exists " - f"for contextKey={contextKey!r} — returning empty set" - ) - itemIds = set() + nk = str(raw) + display = None + lbl = item.get(label_attr) + if lbl is not None and lbl != "": + display = str(lbl) + if display is None: + display = nk + counts[nk] += 1 + if nk not in display_by_key: + display_by_key[nk] = display - return GroupingContext(groupTree=groupTree, itemIds=itemIds) + ordered_keys = sorted( + counts.keys(), + key=lambda x: (x == null_key, str(display_by_key.get(x, x)).lower()), + ) + return [ + { + "value": None if nk == null_key else nk, + "label": display_by_key.get(nk, nk), + "totalCount": counts[nk], + } + for nk in ordered_keys + ] -def applyGroupScopeFilter(items: List[Dict[str, Any]], itemIds: Optional[set]) -> List[Dict[str, Any]]: +def buildGroupLayout( + all_items: List[Dict[str, Any]], + groupByLevels: List[Dict[str, Any]], + page: int, + pageSize: int, +) -> tuple: """ - Filter items to those whose "id" field is in itemIds. - Returns items unchanged when itemIds is None (no active group scope). - Works for both normal list items and for mode=ids / mode=filterValues flows - — call it before handleIdsInMemory / handleFilterValuesInMemory. + 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) """ - if itemIds is None: - return items - return [item for item in items if str(item.get("id", "")) in itemIds] + 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/routeTableViews.py b/modules/routes/routeTableViews.py new file mode 100644 index 00000000..1b4b2d04 --- /dev/null +++ b/modules/routes/routeTableViews.py @@ -0,0 +1,177 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +CRUD endpoints for saved table views (TableListView). + +A view stores a named preset of filters, sort order, and groupByLevels for a +specific table (identified by contextKey). Views are per-user and optionally +per-mandate. + +Route prefix: /api/table-views +""" + +import logging +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request +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 + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/table-views", + tags=["Table Views"], + responses={404: {"description": "Not found"}}, +) + + +def _ownedOrRaise(view: Optional[TableListView], viewId: str, userId: str): + """Raise 404 when view is missing; ownership is implicitly guaranteed by the + interface layer (views are always queried with the current userId).""" + if view is None: + raise HTTPException(status_code=404, detail=f"View '{viewId}' not found") + return view + + +# --------------------------------------------------------------------------- +# List views for a context +# --------------------------------------------------------------------------- + +@router.get("") +@limiter.limit("60/minute") +def list_views( + request: Request, + contextKey: str = Query(..., description="Table context key, e.g. 'connections', 'files/list'"), + currentUser: User = Depends(getCurrentUser), +): + """List all saved views for the current user and contextKey.""" + iface = interfaceDbApp.getInterface(currentUser) + views = iface.getTableListViews(contextKey=contextKey) + return [v.model_dump() if hasattr(v, "model_dump") else v for v in views] + + +# --------------------------------------------------------------------------- +# Get one view +# --------------------------------------------------------------------------- + +@router.get("/{viewKey}") +@limiter.limit("60/minute") +def get_view( + request: Request, + viewKey: str = Path(..., description="View slug"), + contextKey: str = Query(..., description="Table context key"), + currentUser: User = Depends(getCurrentUser), +): + """Return a single saved view by its viewKey.""" + iface = interfaceDbApp.getInterface(currentUser) + view = iface.getTableListView(contextKey=contextKey, viewKey=viewKey) + if view is None: + raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'") + return view.model_dump() if hasattr(view, "model_dump") else view + + +# --------------------------------------------------------------------------- +# Create a view +# --------------------------------------------------------------------------- + +@router.post("", status_code=status.HTTP_201_CREATED) +@limiter.limit("30/minute") +def create_view( + request: Request, + body: dict = Body(...), + currentUser: User = Depends(getCurrentUser), +): + """ + Create a new saved view. + + Body fields: + - contextKey (required): table context key + - viewKey (required): short slug, unique per (user, contextKey) + - displayName (required): human-readable label + - config (optional): view config dict with keys: + schemaVersion, filters, sort, groupByLevels + """ + contextKey = body.get("contextKey") + viewKey = body.get("viewKey") + displayName = body.get("displayName") + config = body.get("config") or {} + + if not contextKey: + raise HTTPException(status_code=400, detail="contextKey is required") + if not viewKey: + raise HTTPException(status_code=400, detail="viewKey is required") + if not displayName: + raise HTTPException(status_code=400, detail="displayName is required") + + iface = interfaceDbApp.getInterface(currentUser) + try: + view = iface.createTableListView( + contextKey=contextKey, + viewKey=viewKey, + displayName=displayName, + config=config, + ) + return view.model_dump() if hasattr(view, "model_dump") else view + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + except Exception as e: + logger.error(f"create_view failed: {e}") + raise HTTPException(status_code=500, detail="Failed to create view") + + +# --------------------------------------------------------------------------- +# Update a view (by id) +# --------------------------------------------------------------------------- + +@router.put("/{viewId}") +@limiter.limit("30/minute") +def update_view( + request: Request, + viewId: str = Path(..., description="View primary-key id (not viewKey)"), + body: dict = Body(...), + currentUser: User = Depends(getCurrentUser), +): + """ + Update an existing view. + + Updatable fields: displayName, viewKey, config. + The contextKey cannot be changed after creation. + """ + allowed = {"displayName", "viewKey", "config"} + updates = {k: v for k, v in body.items() if k in allowed} + if not updates: + raise HTTPException(status_code=400, detail=f"No updatable fields provided. Allowed: {allowed}") + + iface = interfaceDbApp.getInterface(currentUser) + try: + updated = iface.updateTableListView(viewId=viewId, updates=updates) + except Exception as e: + logger.error(f"update_view failed: {e}") + raise HTTPException(status_code=500, detail="Failed to update view") + + if updated is None: + raise HTTPException(status_code=404, detail=f"View id='{viewId}' not found") + return updated.model_dump() if hasattr(updated, "model_dump") else updated + + +# --------------------------------------------------------------------------- +# Delete a view (by id) +# --------------------------------------------------------------------------- + +@router.delete("/{viewId}", status_code=status.HTTP_204_NO_CONTENT) +@limiter.limit("30/minute") +def delete_view( + request: Request, + viewId: str = Path(..., description="View primary-key id"), + currentUser: User = Depends(getCurrentUser), +): + """Delete a saved view by its primary-key id.""" + iface = interfaceDbApp.getInterface(currentUser) + deleted = iface.deleteTableListView(viewId=viewId) + if not deleted: + raise HTTPException(status_code=404, detail=f"View id='{viewId}' not found or could not be deleted") diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py index 37116ee5..ceb6e025 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py @@ -61,34 +61,8 @@ async def _getOrCreateInstanceGroup( featureInstanceId: str, contextKey: str = "files/list", ) -> Optional[str]: - """Return groupId of the default group for a feature instance; create if needed.""" - try: - existing = appInterface.getTableGrouping(contextKey) - nodes = [ - n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) - for n in (existing.rootGroups if existing else []) - ] - - def _find(nds): - for nd in nds: - meta = nd.get("meta", {}) if isinstance(nd, dict) else getattr(nd, "meta", {}) - if (meta or {}).get("featureInstanceId") == featureInstanceId: - return nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None) - found = _find(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", [])) - if found: - return found - return None - - found = _find(nodes) - if found: - return found - newId = str(uuid.uuid4()) - nodes.append({"id": newId, "name": featureInstanceId, "itemIds": [], "subGroups": [], "meta": {"featureInstanceId": featureInstanceId}}) - appInterface.upsertTableGrouping(contextKey, nodes) - return newId - except Exception as e: - logger.error(f"_getOrCreateInstanceGroup: {e}") - return None + """Stub — file group tree removed. Returns None; callers that checked the result will skip group assignment.""" + return None async def _getOrCreateTempGroup( @@ -96,8 +70,8 @@ async def _getOrCreateTempGroup( sessionId: str, contextKey: str = "files/list", ) -> Optional[str]: - """Return groupId of a temporary group for a session; create if needed.""" - return await _getOrCreateInstanceGroup(appInterface, f"_temp_{sessionId}", contextKey) + """Stub — file group tree removed. Returns None.""" + return None def _attachFileAsChatDocument( diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py index 3b9f5945..2ffc808e 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py @@ -312,52 +312,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") if fiId: dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId}) - if args.get("groupId"): - try: - appIface = chatService.interfaceDbApp - existing = appIface.getTableGrouping("files/list") - nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])] - def _addToGroup(nds, gid, fid): - for nd in nds: - nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None) - if nid == gid: - ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", [])) - if fid not in ids: - ids.append(fid) - if isinstance(nd, dict): - nd["itemIds"] = ids - return True - if _addToGroup(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []), gid, fid): - return True - return False - _addToGroup(nodes, args["groupId"], fileItem.id) - appIface.upsertTableGrouping("files/list", nodes) - except Exception as _ge: - logger.warning(f"writeFile: failed to add file to group {args['groupId']}: {_ge}") - elif fiId: - try: - appIface = chatService.interfaceDbApp - instanceGroupId = await _getOrCreateInstanceGroup(appIface, fiId) - if instanceGroupId: - existing = appIface.getTableGrouping("files/list") - nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])] - def _addToGroup2(nds, gid, fid): - for nd in nds: - nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None) - if nid == gid: - ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", [])) - if fid not in ids: - ids.append(fid) - if isinstance(nd, dict): - nd["itemIds"] = ids - return True - if _addToGroup2(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []), gid, fid): - return True - return False - _addToGroup2(nodes, instanceGroupId, fileItem.id) - appIface.upsertTableGrouping("files/list", nodes) - except Exception as _ge: - logger.warning(f"writeFile: failed to add file to instance group for {fiId}: {_ge}") + # File group tree removed — groupId arg and instance-group assignment no longer apply if args.get("tags"): dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]}) @@ -746,136 +701,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): readOnly=False ) - # ---- Group tools (replaces folder-based tools) ---- - - async def _listGroups(args: Dict[str, Any], context: Dict[str, Any]): - contextKey = args.get("contextKey", "files/list") - try: - chatService = services.chat - appInterface = chatService.interfaceDbApp - existing = appInterface.getTableGrouping(contextKey) - if not existing: - return ToolResult(toolCallId="", toolName="listGroups", success=True, data="No groups found.") - - def _flatten(nodes, depth=0): - result = [] - for n in nodes: - nd = n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) - result.append({"id": nd.get("id"), "name": nd.get("name"), "depth": depth, "itemCount": len(nd.get("itemIds", []))}) - result.extend(_flatten(nd.get("subGroups", []), depth + 1)) - return result - - groups = _flatten(existing.rootGroups) - lines = "\n".join( - f"{' ' * g['depth']}- {g['name']} (id: {g['id']}, items: {g['itemCount']})" - for g in groups - ) if groups else "No groups found." - return ToolResult(toolCallId="", toolName="listGroups", success=True, data=lines) - except Exception as e: - return ToolResult(toolCallId="", toolName="listGroups", success=False, error=str(e)) - - async def _listItemsInGroup(args: Dict[str, Any], context: Dict[str, Any]): - groupId = args.get("groupId", "") - contextKey = args.get("contextKey", "files/list") - if not groupId: - return ToolResult(toolCallId="", toolName="listItemsInGroup", success=False, error="groupId is required") - try: - from modules.routes.routeHelpers import _collectItemIds - chatService = services.chat - appInterface = chatService.interfaceDbApp - existing = appInterface.getTableGrouping(contextKey) - if not existing: - return ToolResult(toolCallId="", toolName="listItemsInGroup", success=True, data="No groups found.") - nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in existing.rootGroups] - ids = _collectItemIds(nodes, groupId) - itemList = list(ids) if ids else [] - return ToolResult( - toolCallId="", toolName="listItemsInGroup", success=True, - data="\n".join(f"- {fid}" for fid in itemList) if itemList else "No items in group.", - ) - except Exception as e: - return ToolResult(toolCallId="", toolName="listItemsInGroup", success=False, error=str(e)) - - async def _addItemsToGroup(args: Dict[str, Any], context: Dict[str, Any]): - groupId = args.get("groupId", "") - itemIds = args.get("itemIds", []) - contextKey = args.get("contextKey", "files/list") - if not groupId: - return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error="groupId is required") - if not itemIds: - return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error="itemIds is required") - try: - chatService = services.chat - appInterface = chatService.interfaceDbApp - existing = appInterface.getTableGrouping(contextKey) - nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])] - - def _add(nds): - for nd in nds: - nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None) - if nid == groupId: - existing_ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", [])) - for fid in itemIds: - if fid not in existing_ids: - existing_ids.append(fid) - if isinstance(nd, dict): - nd["itemIds"] = existing_ids - return True - if _add(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", [])): - return True - return False - - found = _add(nodes) - if not found: - return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error=f"Group {groupId} not found") - appInterface.upsertTableGrouping(contextKey, nodes) - return ToolResult( - toolCallId="", toolName="addItemsToGroup", success=True, - data=f"Added {len(itemIds)} item(s) to group {groupId}", - ) - except Exception as e: - return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error=str(e)) - - registry.register( - "listGroups", _listGroups, - description="List all groups in the file grouping tree. Groups replace folders for organising files.", - parameters={ - "type": "object", - "properties": { - "contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"}, - } - }, - readOnly=True - ) - - registry.register( - "listItemsInGroup", _listItemsInGroup, - description="List all file IDs assigned to a specific group (includes sub-groups recursively).", - parameters={ - "type": "object", - "properties": { - "groupId": {"type": "string", "description": "The group ID to inspect"}, - "contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"}, - }, - "required": ["groupId"] - }, - readOnly=True - ) - - registry.register( - "addItemsToGroup", _addItemsToGroup, - description="Add one or more file IDs to an existing group.", - parameters={ - "type": "object", - "properties": { - "groupId": {"type": "string", "description": "The group ID to add files to"}, - "itemIds": {"type": "array", "items": {"type": "string"}, "description": "List of file IDs to add"}, - "contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"}, - }, - "required": ["groupId", "itemIds"] - }, - readOnly=False - ) + # Group tree tools removed — file grouping now uses view-based display grouping (TableListView) registry.register( "replaceInFile", _replaceInFile, diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index b5c7a542..1e2d19f1 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -523,34 +523,12 @@ class ChatService: return results def listGroups(self, contextKey: str = "files/list") -> list: - """List all groups in the groupTree for the current context.""" - try: - existing = self.interfaceDbApp.getTableGrouping(contextKey) - if not existing: - return [] - def _flatten(nodes, depth=0): - result = [] - for n in nodes: - nd = n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) - result.append({"id": nd.get("id"), "name": nd.get("name"), "depth": depth, "itemCount": len(nd.get("itemIds", []))}) - result.extend(_flatten(nd.get("subGroups", []), depth + 1)) - return result - return _flatten(existing.rootGroups) - except Exception as e: - return [] + """Stub — file group tree removed. Returns empty list.""" + return [] def listFilesInGroup(self, groupId: str, contextKey: str = "files/list") -> list: - """List file IDs in a specific group (recursive).""" - try: - from modules.routes.routeHelpers import _collectItemIds - existing = self.interfaceDbApp.getTableGrouping(contextKey) - if not existing: - return [] - nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in existing.rootGroups] - ids = _collectItemIds(nodes, groupId) - return list(ids) if ids else [] - except Exception: - return [] + """Stub — file group tree removed. Returns empty list.""" + return [] # ---- DataSource CRUD ---- From dac9911f8b8260341304a661dd442c8b611a8398 Mon Sep 17 00:00:00 2001 From: Ida Date: Tue, 5 May 2026 16:06:05 +0200 Subject: [PATCH 07/14] removed git merge conflicts --- .../features/graphicalEditor/nodeDefinitions/file.py | 4 ---- modules/features/graphicalEditor/portTypes.py | 3 --- .../services/serviceAi/subAiCallLooping.py | 8 +------- .../serviceGeneration/renderers/rendererCsv.py | 10 ---------- 4 files changed, 1 insertion(+), 24 deletions(-) diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index a5390016..ffa4d722 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -20,11 +20,7 @@ FILE_NODES = [ ], "inputs": 1, "outputs": 1, -<<<<<<< HEAD "inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload", "LoopItem", "ActionResult"]}}, -======= - "inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload"]}}, ->>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False}, "_method": "file", diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index af0759f5..deab83b9 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -723,12 +723,9 @@ def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]: if not schema or schemaName == "Transit": return result -<<<<<<< HEAD if schemaName == "DocumentList": _coerce_document_list_upload_fields(result) -======= ->>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) # Only default **required** fields. Optional fields stay absent so DataRefs / context # resolution never pick a synthetic `{}` or `[]` (e.g. AiResult.responseData when the # model returned plain text only). diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index ed8ddcda..de746483 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -559,7 +559,6 @@ class AiCallLooper: if iteration >= maxIterations: logger.warning(f"AI call stopped after maximum iterations ({maxIterations})") -<<<<<<< HEAD # Prefer last repaired complete JSON from getContexts (raw `result` is only the last fragment). if lastValidCompletePart and useCase and not useCase.requiresExtraction: try: @@ -577,12 +576,7 @@ class AiCallLooper: ) except Exception as e: logger.debug("Max-iterations fallback on completePart failed: %s", e) - -======= - # This code path should never be reached because all registered use cases - # return early when JSON is complete. This would only execute for use cases that - # require section extraction, but no such use cases are currently registered. ->>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) + logger.error( "End of callAiWithLooping without success for use case %r (iterations=%s, lastResultLen=%s)", useCaseId, diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py index 2c8d35b3..a8b2c346 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py @@ -39,12 +39,6 @@ class RendererCsv(BaseRenderer): """ return ["table", "code_block"] -<<<<<<< HEAD -======= -<<<<<<< HEAD - async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]: -======= ->>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) async def render( self, extractedContent: Dict[str, Any], @@ -54,10 +48,6 @@ class RendererCsv(BaseRenderer): *, style: Dict[str, Any] = None, ) -> List[RenderedDocument]: -<<<<<<< HEAD -======= ->>>>>>> 0659d0d2 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) ->>>>>>> 875f8252 (ValueOn Lead to Offer durchgespielt, bugfixes in Dateigenerierung und ai nodes) """Render extracted JSON content to CSV format. Produces one CSV file per table section.""" _ = style try: From cfd303792ff2ad74b3ca886ffd394a71d54259d8 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 6 May 2026 23:28:22 +0200 Subject: [PATCH 08/14] refactored comcoach und teamsbot --- .../features/commcoach/datamodelCommcoach.py | 82 ++-- .../commcoach/interfaceFeatureCommcoach.py | 180 +++++---- modules/features/commcoach/mainCommcoach.py | 233 +++++++++-- .../commcoach/routeFeatureCommcoach.py | 367 +++++++++++------- .../features/commcoach/serviceCommcoach.py | 46 ++- .../commcoach/serviceCommcoachGamification.py | 2 +- .../commcoach/serviceCommcoachPersonas.py | 51 +++ .../commcoach/serviceCommcoachScheduler.py | 8 +- .../features/teamsbot/datamodelTeamsbot.py | 53 +++ .../teamsbot/interfaceFeatureTeamsbot.py | 46 ++- modules/features/teamsbot/mainTeamsbot.py | 131 ++++++- .../features/teamsbot/routeFeatureTeamsbot.py | 102 ++++- modules/features/teamsbot/service.py | 2 + .../migration/seedData/ui_language_seed.json | 20 + .../serviceAgent/actionToolAdapter.py | 111 ++++-- .../coreTools/_dataSourceTools.py | 15 +- .../serviceAgent/coreTools/_helpers.py | 30 +- .../serviceAgent/coreTools/_mediaTools.py | 38 +- .../services/serviceAgent/mainServiceAgent.py | 3 +- .../services/serviceAi/subAiCallLooping.py | 7 + .../services/serviceAi/subStructureFilling.py | 65 +--- .../services/serviceChat/mainServiceChat.py | 2 +- .../renderers/documentRendererBaseTemplate.py | 12 + .../renderers/rendererPdf.py | 20 +- .../serviceGeneration/styleDefaults.py | 8 +- .../executors/actionNodeExecutor.py | 21 +- .../methods/methodAi/actions/process.py | 6 +- 27 files changed, 1244 insertions(+), 417 deletions(-) diff --git a/modules/features/commcoach/datamodelCommcoach.py b/modules/features/commcoach/datamodelCommcoach.py index afc14df5..250d4799 100644 --- a/modules/features/commcoach/datamodelCommcoach.py +++ b/modules/features/commcoach/datamodelCommcoach.py @@ -2,7 +2,7 @@ # All rights reserved. """ CommCoach Feature - Data Models. -Pydantic models for coaching contexts, sessions, messages, tasks, scores, and user profiles. +Pydantic models for training modules, sessions, messages, tasks, scores, and user profiles. """ from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field @@ -16,22 +16,18 @@ import uuid # Enums # ============================================================================ -class CoachingContextStatus(str, Enum): +class TrainingModuleStatus(str, Enum): ACTIVE = "active" PAUSED = "paused" ARCHIVED = "archived" COMPLETED = "completed" -class CoachingContextCategory(str, Enum): - LEADERSHIP = "leadership" - CONFLICT = "conflict" - NEGOTIATION = "negotiation" - PRESENTATION = "presentation" - FEEDBACK = "feedback" - DELEGATION = "delegation" - CHANGE_MANAGEMENT = "changeManagement" - CUSTOM = "custom" +class TrainingModuleType(str, Enum): + COACHING = "coaching" + TRAINING = "training" + EXAM = "exam" + ELEARNING = "elearning" class CoachingSessionStatus(str, Enum): @@ -75,19 +71,21 @@ class CoachingScoreTrend(str, Enum): # Database Models # ============================================================================ -class CoachingContext(PowerOnModel): - """A coaching context/dossier representing a topic the user is working on.""" +class TrainingModule(PowerOnModel): + """A training module representing a topic the user is working on.""" id: str = Field(default_factory=lambda: str(uuid.uuid4())) userId: str = Field(description="Owner user ID (strict ownership)") mandateId: str = Field(description="Mandate ID") instanceId: str = Field(description="Feature instance ID") - title: str = Field(description="Context title, e.g. 'Conflict with team lead'") + title: str = Field(description="Module title, e.g. 'Conflict with team lead'") description: Optional[str] = Field(default=None, description="Short description") - category: CoachingContextCategory = Field(default=CoachingContextCategory.CUSTOM) - status: CoachingContextStatus = Field(default=CoachingContextStatus.ACTIVE) - goals: Optional[str] = Field(default=None, description="JSON array of goals [{id, text, status, createdAt}]") + moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING) + status: TrainingModuleStatus = Field(default=TrainingModuleStatus.ACTIVE) + goals: Optional[str] = Field(default=None, description="Free-text goal description") insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]") metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata") + personaId: Optional[str] = Field(default=None, description="Default persona for sessions") + kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets") sessionCount: int = Field(default=0) taskCount: int = Field(default=0) lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"}) @@ -96,9 +94,9 @@ class CoachingContext(PowerOnModel): class CoachingSession(PowerOnModel): - """A single coaching conversation session within a context.""" + """A single coaching conversation session within a module.""" id: str = Field(default_factory=lambda: str(uuid.uuid4())) - contextId: str = Field(description="FK to CoachingContext") + moduleId: str = Field(description="FK to TrainingModule") userId: str = Field(description="Owner user ID") mandateId: str = Field(description="Mandate ID") instanceId: str = Field(description="Feature instance ID") @@ -121,7 +119,7 @@ class CoachingMessage(PowerOnModel): """A single message in a coaching session.""" id: str = Field(default_factory=lambda: str(uuid.uuid4())) sessionId: str = Field(description="FK to CoachingSession") - contextId: str = Field(description="FK to CoachingContext") + moduleId: str = Field(description="FK to TrainingModule") userId: str = Field(description="Owner user ID") role: CoachingMessageRole = Field(description="Message author role") content: str = Field(description="Message content (Markdown)") @@ -131,9 +129,9 @@ class CoachingMessage(PowerOnModel): class CoachingTask(PowerOnModel): - """A task/checklist item assigned within a coaching context.""" + """A task/checklist item assigned within a training module.""" id: str = Field(default_factory=lambda: str(uuid.uuid4())) - contextId: str = Field(description="FK to CoachingContext") + moduleId: str = Field(description="FK to TrainingModule") sessionId: Optional[str] = Field(default=None, description="FK to originating session") userId: str = Field(description="Owner user ID") mandateId: str = Field(description="Mandate ID") @@ -148,7 +146,7 @@ class CoachingTask(PowerOnModel): class CoachingScore(PowerOnModel): """A competence score for a dimension, recorded after a session.""" id: str = Field(default_factory=lambda: str(uuid.uuid4())) - contextId: str = Field(description="FK to CoachingContext") + moduleId: str = Field(description="FK to TrainingModule") sessionId: str = Field(description="FK to CoachingSession") userId: str = Field(description="Owner user ID") mandateId: str = Field(description="Mandate ID") @@ -193,6 +191,22 @@ class CoachingPersona(PowerOnModel): isActive: bool = Field(default=True) +# ============================================================================ +# Module-Persona Mapping (M:N) +# ============================================================================ + +class ModulePersonaMapping(PowerOnModel): + """Maps which personas are available for a specific training module.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + moduleId: str = Field(description="FK to TrainingModule") + personaId: str = Field(description="FK to CoachingPersona") + instanceId: str = Field(description="Feature instance ID") + + +class SetModulePersonasRequest(BaseModel): + personaIds: List[str] = Field(description="List of persona IDs to assign to this module") + + # ============================================================================ # Iteration 2: Badges / Gamification # ============================================================================ @@ -211,18 +225,22 @@ class CoachingBadge(PowerOnModel): # API Request/Response Models # ============================================================================ -class CreateContextRequest(BaseModel): - title: str = Field(description="Context title") +class CreateModuleRequest(BaseModel): + title: str = Field(description="Module title") description: Optional[str] = None - category: Optional[CoachingContextCategory] = CoachingContextCategory.CUSTOM - goals: Optional[List[str]] = None + moduleType: Optional[TrainingModuleType] = TrainingModuleType.COACHING + goals: Optional[str] = None + personaId: Optional[str] = None + kpiTargets: Optional[str] = None -class UpdateContextRequest(BaseModel): +class UpdateModuleRequest(BaseModel): title: Optional[str] = None description: Optional[str] = None - category: Optional[CoachingContextCategory] = None + moduleType: Optional[TrainingModuleType] = None goals: Optional[str] = None + personaId: Optional[str] = None + kpiTargets: Optional[str] = None class SendMessageRequest(BaseModel): @@ -279,8 +297,8 @@ class UpdatePersonaRequest(BaseModel): class DashboardData(BaseModel): """Aggregated dashboard data for the user.""" - totalContexts: int = 0 - activeContexts: int = 0 + totalModules: int = 0 + activeModules: int = 0 totalSessions: int = 0 totalMinutes: int = 0 streakDays: int = 0 @@ -289,4 +307,4 @@ class DashboardData(BaseModel): recentScores: List[Dict[str, Any]] = Field(default_factory=list) openTasks: int = 0 completedTasks: int = 0 - contexts: List[Dict[str, Any]] = Field(default_factory=list) + modules: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index e4485591..a6fd41ec 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -17,7 +17,7 @@ from modules.shared.configuration import APP_CONFIG from modules.shared.i18nRegistry import resolveText, t from .datamodelCommcoach import ( - CoachingContext, CoachingContextStatus, + TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus, CoachingMessage, CoachingTask, CoachingTaskStatus, @@ -70,47 +70,60 @@ class CommcoachObjects: ) # ========================================================================= - # Contexts + # Modules (formerly Contexts) # ========================================================================= - def getContexts(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]: - """Get all coaching contexts for a user. Strict ownership.""" + def getModules(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]: + """Get all training modules for a user. Enriches with live sessionCount from sessions table.""" records = self.db.getRecordset( - CoachingContext, + TrainingModule, recordFilter={"instanceId": instanceId, "userId": userId}, ) if not includeArchived: - records = [r for r in records if r.get("status") != CoachingContextStatus.ARCHIVED.value] + records = [r for r in records if r.get("status") != TrainingModuleStatus.ARCHIVED.value] + + allSessions = self.db.getRecordset( + CoachingSession, + recordFilter={"instanceId": instanceId, "userId": userId}, + ) + countByModule: Dict[str, int] = {} + for s in allSessions: + mid = s.get("moduleId") + if mid: + countByModule[mid] = countByModule.get(mid, 0) + 1 + for r in records: + r["sessionCount"] = countByModule.get(r.get("id", ""), 0) + records.sort(key=lambda r: r.get("updatedAt") or r.get("createdAt") or "", reverse=True) return records - def getContext(self, contextId: str) -> Optional[Dict[str, Any]]: - records = self.db.getRecordset(CoachingContext, recordFilter={"id": contextId}) + def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]: + records = self.db.getRecordset(TrainingModule, recordFilter={"id": moduleId}) return records[0] if records else None - def createContext(self, data: Dict[str, Any]) -> Dict[str, Any]: + def createModule(self, data: Dict[str, Any]) -> Dict[str, Any]: data["createdAt"] = getIsoTimestamp() data["updatedAt"] = getIsoTimestamp() - return self.db.recordCreate(CoachingContext, data) + return self.db.recordCreate(TrainingModule, data) - def updateContext(self, contextId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def updateModule(self, moduleId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: updates["updatedAt"] = getIsoTimestamp() - return self.db.recordModify(CoachingContext, contextId, updates) + return self.db.recordModify(TrainingModule, moduleId, updates) - def deleteContext(self, contextId: str) -> bool: - self._deleteSessionsByContext(contextId) - self._deleteTasksByContext(contextId) - self._deleteScoresByContext(contextId) - return self.db.recordDelete(CoachingContext, contextId) + def deleteModule(self, moduleId: str) -> bool: + self._deleteSessionsByModule(moduleId) + self._deleteTasksByModule(moduleId) + self._deleteScoresByModule(moduleId) + return self.db.recordDelete(TrainingModule, moduleId) # ========================================================================= # Sessions # ========================================================================= - def getSessions(self, contextId: str, userId: str) -> List[Dict[str, Any]]: + def getSessions(self, moduleId: str, userId: str) -> List[Dict[str, Any]]: records = self.db.getRecordset( CoachingSession, - recordFilter={"contextId": contextId, "userId": userId}, + recordFilter={"moduleId": moduleId, "userId": userId}, ) records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True) return records @@ -119,10 +132,10 @@ class CommcoachObjects: records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId}) return records[0] if records else None - def getActiveSession(self, contextId: str, userId: str) -> Optional[Dict[str, Any]]: + def getActiveSession(self, moduleId: str, userId: str) -> Optional[Dict[str, Any]]: records = self.db.getRecordset( CoachingSession, - recordFilter={"contextId": contextId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value}, + recordFilter={"moduleId": moduleId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value}, ) return records[0] if records else None @@ -136,8 +149,8 @@ class CommcoachObjects: updates["updatedAt"] = getIsoTimestamp() return self.db.recordModify(CoachingSession, sessionId, updates) - def _deleteSessionsByContext(self, contextId: str) -> int: - records = self.db.getRecordset(CoachingSession, recordFilter={"contextId": contextId}) + def _deleteSessionsByModule(self, moduleId: str) -> int: + records = self.db.getRecordset(CoachingSession, recordFilter={"moduleId": moduleId}) count = 0 for record in records: self._deleteMessagesBySession(record.get("id")) @@ -174,10 +187,10 @@ class CommcoachObjects: # Tasks # ========================================================================= - def getTasks(self, contextId: str, userId: str) -> List[Dict[str, Any]]: + def getTasks(self, moduleId: str, userId: str) -> List[Dict[str, Any]]: records = self.db.getRecordset( CoachingTask, - recordFilter={"contextId": contextId, "userId": userId}, + recordFilter={"moduleId": moduleId, "userId": userId}, ) records.sort(key=lambda r: r.get("createdAt") or "", reverse=True) return records @@ -198,8 +211,8 @@ class CommcoachObjects: def deleteTask(self, taskId: str) -> bool: return self.db.recordDelete(CoachingTask, taskId) - def _deleteTasksByContext(self, contextId: str) -> int: - records = self.db.getRecordset(CoachingTask, recordFilter={"contextId": contextId}) + def _deleteTasksByModule(self, moduleId: str) -> int: + records = self.db.getRecordset(CoachingTask, recordFilter={"moduleId": moduleId}) count = 0 for record in records: self.db.recordDelete(CoachingTask, record.get("id")) @@ -218,10 +231,10 @@ class CommcoachObjects: # Scores # ========================================================================= - def getScores(self, contextId: str, userId: str) -> List[Dict[str, Any]]: + def getScores(self, moduleId: str, userId: str) -> List[Dict[str, Any]]: records = self.db.getRecordset( CoachingScore, - recordFilter={"contextId": contextId, "userId": userId}, + recordFilter={"moduleId": moduleId, "userId": userId}, ) records.sort(key=lambda r: r.get("createdAt") or "") return records @@ -235,8 +248,8 @@ class CommcoachObjects: data["createdAt"] = getIsoTimestamp() return self.db.recordCreate(CoachingScore, data) - def _deleteScoresByContext(self, contextId: str) -> int: - records = self.db.getRecordset(CoachingScore, recordFilter={"contextId": contextId}) + def _deleteScoresByModule(self, moduleId: str) -> int: + records = self.db.getRecordset(CoachingScore, recordFilter={"moduleId": moduleId}) count = 0 for record in records: self.db.recordDelete(CoachingScore, record.get("id")) @@ -274,6 +287,39 @@ class CommcoachObjects: from .datamodelCommcoach import CoachingPersona return self.db.recordDelete(CoachingPersona, personaId) + def getAllPersonas(self, instanceId: str) -> List[Dict[str, Any]]: + """All personas (builtin + custom for this instance), including inactive.""" + from .datamodelCommcoach import CoachingPersona + builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"}) + custom = self.db.getRecordset(CoachingPersona, recordFilter={"instanceId": instanceId}) + custom = [p for p in custom if p.get("userId") != "system"] + return builtins + custom + + # ========================================================================= + # Module-Persona Mapping + # ========================================================================= + + def getModulePersonas(self, moduleId: str) -> List[Dict[str, Any]]: + from .datamodelCommcoach import ModulePersonaMapping + return self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId}) + + def setModulePersonas(self, moduleId: str, personaIds: List[str], instanceId: str) -> List[Dict[str, Any]]: + from .datamodelCommcoach import ModulePersonaMapping + existing = self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId}) + for rec in existing: + self.db.recordDelete(ModulePersonaMapping, rec["id"]) + created = [] + for pId in personaIds: + data = ModulePersonaMapping( + moduleId=moduleId, + personaId=pId, + instanceId=instanceId, + ).model_dump() + data["createdAt"] = getIsoTimestamp() + data["updatedAt"] = getIsoTimestamp() + created.append(self.db.recordCreate(ModulePersonaMapping, data)) + return created + # ========================================================================= # Badges # ========================================================================= @@ -299,8 +345,8 @@ class CommcoachObjects: # Score History # ========================================================================= - def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]: - scores = self.getScores(contextId, userId) + def getScoreHistory(self, moduleId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]: + scores = self.getScores(moduleId, userId) history: Dict[str, List[Dict[str, Any]]] = {} for s in scores: dim = s.get("dimension", "unknown") @@ -344,16 +390,15 @@ class CommcoachObjects: # ========================================================================= def getDashboardData(self, userId: str, instanceId: str) -> Dict[str, Any]: - contexts = self.db.getRecordset(CoachingContext, recordFilter={"userId": userId, "instanceId": instanceId}) + modules = self.db.getRecordset(TrainingModule, recordFilter={"userId": userId, "instanceId": instanceId}) sessions = self.db.getRecordset(CoachingSession, recordFilter={"userId": userId, "instanceId": instanceId}) profile = self.getProfile(userId, instanceId) - activeContexts = [c for c in contexts if c.get("status") == CoachingContextStatus.ACTIVE.value] - completedSessions = [s for s in sessions if s.get("status") == CoachingSessionStatus.COMPLETED.value] + activeModules = [m for m in modules if m.get("status") == TrainingModuleStatus.ACTIVE.value] - totalMinutes = sum(s.get("durationSeconds", 0) for s in completedSessions) // 60 + totalMinutes = sum(s.get("durationSeconds", 0) for s in sessions) // 60 scores = [] - for s in completedSessions: + for s in sessions: raw = s.get("competenceScore") if raw is not None: try: @@ -364,29 +409,27 @@ class CommcoachObjects: recentScores = self.getRecentScores(userId, limit=10) - contextSummaries = [] - for ctx in activeContexts: - goalProgress = _calcGoalProgress(ctx.get("goals")) - contextSummaries.append({ - "id": ctx.get("id"), - "title": ctx.get("title"), - "category": ctx.get("category"), - "sessionCount": ctx.get("sessionCount", 0), - "lastSessionAt": ctx.get("lastSessionAt"), - "goalProgress": goalProgress, + countByModule: Dict[str, int] = {} + for s in sessions: + mid = s.get("moduleId") + if mid: + countByModule[mid] = countByModule.get(mid, 0) + 1 + + moduleSummaries = [] + for mod in activeModules: + modId = mod.get("id", "") + moduleSummaries.append({ + "id": modId, + "title": mod.get("title"), + "moduleType": mod.get("moduleType"), + "sessionCount": countByModule.get(modId, 0), + "lastSessionAt": mod.get("lastSessionAt"), }) - allGoalProgress = [] - for ctx in activeContexts: - gp = _calcGoalProgress(ctx.get("goals")) - if gp is not None: - allGoalProgress.append(gp) - overallGoalProgress = round(sum(allGoalProgress) / len(allGoalProgress)) if allGoalProgress else None - return { - "totalContexts": len(contexts), - "activeContexts": len(activeContexts), - "totalSessions": len(completedSessions), + "totalModules": len(modules), + "activeModules": len(activeModules), + "totalSessions": len(sessions), "totalMinutes": totalMinutes, "streakDays": profile.get("streakDays", 0) if profile else 0, "longestStreak": profile.get("longestStreak", 0) if profile else 0, @@ -394,29 +437,12 @@ class CommcoachObjects: "recentScores": recentScores, "openTasks": self.getOpenTaskCount(userId, instanceId), "completedTasks": self.getCompletedTaskCount(userId, instanceId), - "contexts": contextSummaries, - "goalProgress": overallGoalProgress, + "modules": moduleSummaries, "badges": self.getBadges(userId, instanceId), - "level": _calcLevel(profile.get("totalSessions", 0) if profile else 0), + "level": _calcLevel(len(sessions)), } -def _calcGoalProgress(goalsRaw) -> Optional[int]: - """Calculate goal completion percentage from a context's goals JSON field.""" - if not goalsRaw: - return None - goals = goalsRaw - if isinstance(goalsRaw, str): - try: - goals = json.loads(goalsRaw) - except (json.JSONDecodeError, TypeError): - return None - if not isinstance(goals, list) or len(goals) == 0: - return None - done = sum(1 for g in goals if isinstance(g, dict) and g.get("status") in ("done", "completed")) - return round(done / len(goals) * 100) - - _LEVELS = [ (50, 5, "master", "Meister"), (25, 4, "expert", "Experte"), diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index 33469a62..6beede11 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -23,9 +23,24 @@ UI_OBJECTS = [ "meta": {"area": "dashboard"} }, { - "objectKey": "ui.feature.commcoach.coaching", - "label": t("Arbeitsthemen", context="UI"), - "meta": {"area": "coaching"} + "objectKey": "ui.feature.commcoach.assistant", + "label": t("Assistent", context="UI"), + "meta": {"area": "assistant"} + }, + { + "objectKey": "ui.feature.commcoach.modules", + "label": t("Module", context="UI"), + "meta": {"area": "modules"} + }, + { + "objectKey": "ui.feature.commcoach.session", + "label": t("Session", context="UI"), + "meta": {"area": "session"} + }, + { + "objectKey": "ui.feature.commcoach.dossier", + "label": t("Dossier", context="UI"), + "meta": {"area": "dossier"} }, { "objectKey": "ui.feature.commcoach.settings", @@ -35,15 +50,15 @@ UI_OBJECTS = [ ] DATA_OBJECTS = [ - # ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ── + # ── Record-Hierarchie: TrainingModule → Session → Message/Score, TrainingModule → Task ── { - "objectKey": "data.feature.commcoach.CoachingContext", - "label": t("Coaching-Kontext", context="UI"), + "objectKey": "data.feature.commcoach.TrainingModule", + "label": t("Trainings-Modul", context="UI"), "meta": { - "table": "CoachingContext", - "fields": ["id", "title", "category", "status", "lastSessionAt"], + "table": "TrainingModule", + "fields": ["id", "title", "moduleType", "status", "lastSessionAt"], "isParent": True, - "displayFields": ["title", "category", "status"], + "displayFields": ["title", "moduleType", "status"], } }, { @@ -51,10 +66,10 @@ DATA_OBJECTS = [ "label": t("Coaching-Session", context="UI"), "meta": { "table": "CoachingSession", - "fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"], + "fields": ["id", "moduleId", "status", "summary", "startedAt", "endedAt", "competenceScore"], "isParent": True, - "parentTable": "CoachingContext", - "parentKey": "contextId", + "parentTable": "TrainingModule", + "parentKey": "moduleId", "displayFields": ["startedAt", "status"], } }, @@ -63,7 +78,7 @@ DATA_OBJECTS = [ "label": t("Coaching-Nachricht", context="UI"), "meta": { "table": "CoachingMessage", - "fields": ["id", "sessionId", "contextId", "role", "content", "contentType"], + "fields": ["id", "sessionId", "moduleId", "role", "content", "contentType"], "parentTable": "CoachingSession", "parentKey": "sessionId", } @@ -73,7 +88,7 @@ DATA_OBJECTS = [ "label": t("Coaching-Score", context="UI"), "meta": { "table": "CoachingScore", - "fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"], + "fields": ["id", "sessionId", "moduleId", "dimension", "score", "trend"], "parentTable": "CoachingSession", "parentKey": "sessionId", } @@ -83,9 +98,9 @@ DATA_OBJECTS = [ "label": t("Coaching-Aufgabe", context="UI"), "meta": { "table": "CoachingTask", - "fields": ["id", "contextId", "title", "status", "priority", "dueDate"], - "parentTable": "CoachingContext", - "parentKey": "contextId", + "fields": ["id", "moduleId", "title", "status", "priority", "dueDate"], + "parentTable": "TrainingModule", + "parentKey": "moduleId", } }, # ── Stammdaten (sessionübergreifend, scoped per userId) ────────────────── @@ -112,6 +127,15 @@ DATA_OBJECTS = [ "fields": ["id", "key", "label", "gender", "category"], } }, + { + "objectKey": "data.feature.commcoach.ModulePersonaMapping", + "label": t("Modul-Persona-Zuordnung", context="UI"), + "meta": { + "table": "ModulePersonaMapping", + "group": "data.feature.commcoach.userData", + "fields": ["id", "moduleId", "personaId", "instanceId"], + } + }, { "objectKey": "data.feature.commcoach.CoachingBadge", "label": t("Coaching-Auszeichnung", context="UI"), @@ -130,19 +154,19 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { - "objectKey": "resource.feature.commcoach.context.create", - "label": t("Kontext erstellen", context="UI"), - "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"} + "objectKey": "resource.feature.commcoach.module.create", + "label": t("Modul erstellen", context="UI"), + "meta": {"endpoint": "/api/commcoach/{instanceId}/modules", "method": "POST"} }, { - "objectKey": "resource.feature.commcoach.context.archive", - "label": t("Kontext archivieren", context="UI"), - "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"} + "objectKey": "resource.feature.commcoach.module.archive", + "label": t("Modul archivieren", context="UI"), + "meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/archive", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.session.start", "label": t("Session starten", context="UI"), - "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"} + "meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/sessions/start", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.session.complete", @@ -152,7 +176,17 @@ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.commcoach.task.manage", "label": t("Aufgaben verwalten", context="UI"), - "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"} + "meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/tasks", "method": "POST"} + }, + { + "objectKey": "resource.feature.commcoach.persona.manage", + "label": t("Persona verwalten", context="UI"), + "meta": {"endpoint": "/api/commcoach/{instanceId}/personas", "method": "POST"} + }, + { + "objectKey": "resource.feature.commcoach.modulePersonas.manage", + "label": t("Modul-Persona-Zuordnung verwalten", context="UI"), + "meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/personas", "method": "PUT"} }, ] @@ -162,28 +196,33 @@ TEMPLATE_ROLES = [ "description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, - {"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.modules", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.session", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, - # Viewer: keine RESOURCE-Endpunkte (Mutationen); Regel explizit fuer konsistente Kontext-Matrix {"context": "RESOURCE", "item": None, "view": False}, ], }, { "roleLabel": "commcoach-user", - "description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten", + "description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Module und Sessions verwalten", "accessRules": [ {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, - {"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.modules", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.session", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, - {"context": "DATA", "item": "data.feature.commcoach.CoachingContext", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, + {"context": "DATA", "item": "data.feature.commcoach.TrainingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingMessage", "view": True, "read": "m", "create": "m", "update": "n", "delete": "n"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingTask", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingScore", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingUserProfile", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, - {"context": "RESOURCE", "item": "resource.feature.commcoach.context.create", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.commcoach.context.archive", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.commcoach.module.create", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.commcoach.module.archive", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True}, @@ -252,6 +291,7 @@ def registerFeature(catalogService) -> bool: meta=dataObj.get("meta") ) + _runMigrations() _syncTemplateRolesToDb() _seedBuiltinPersonas() _registerScheduler() @@ -264,6 +304,135 @@ def registerFeature(catalogService) -> bool: return False +def _runMigrations(): + """Idempotent DB migrations for CommCoach feature. + Runs on every bootstrap; each step checks preconditions before executing. + """ + try: + from .interfaceFeatureCommcoach import commcoachDatabase + from modules.shared.configuration import APP_CONFIG + import psycopg2 + from psycopg2.extras import RealDictCursor + + conn = psycopg2.connect( + host=APP_CONFIG.get("DB_HOST", "localhost"), + database=commcoachDatabase, + user=APP_CONFIG.get("DB_USER"), + password=APP_CONFIG.get("DB_PASSWORD_SECRET"), + port=int(APP_CONFIG.get("DB_PORT", 5432)), + cursor_factory=RealDictCursor, + ) + conn.autocommit = False + cur = conn.cursor() + + def _tableExists(name): + cur.execute( + "SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'", + (name,), + ) + return cur.fetchone() is not None + + def _columnExists(table, column): + cur.execute( + "SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'", + (table, column), + ) + return cur.fetchone() is not None + + migrated = False + + # M1: Rename table CoachingContext -> TrainingModule + if _tableExists("CoachingContext") and not _tableExists("TrainingModule"): + cur.execute('ALTER TABLE "CoachingContext" RENAME TO "TrainingModule"') + logger.info("Migration M1: Renamed table CoachingContext -> TrainingModule") + migrated = True + + # M2: Rename contextId -> moduleId on child tables + for childTable in ["CoachingSession", "CoachingMessage", "CoachingTask", "CoachingScore"]: + if _tableExists(childTable) and _columnExists(childTable, "contextId") and not _columnExists(childTable, "moduleId"): + cur.execute(f'ALTER TABLE "{childTable}" RENAME COLUMN "contextId" TO "moduleId"') + logger.info(f"Migration M2: Renamed contextId -> moduleId on {childTable}") + migrated = True + + # M3: Add moduleType column with default 'coaching' + if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "moduleType"): + cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "moduleType" TEXT DEFAULT \'coaching\'') + cur.execute('UPDATE "TrainingModule" SET "moduleType" = \'coaching\' WHERE "moduleType" IS NULL') + logger.info("Migration M3: Added moduleType column to TrainingModule") + migrated = True + + # M4: Add personaId column + if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "personaId"): + cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "personaId" TEXT') + logger.info("Migration M4: Added personaId column to TrainingModule") + migrated = True + + # M5: Add kpiTargets column + if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "kpiTargets"): + cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "kpiTargets" TEXT') + logger.info("Migration M5: Added kpiTargets column to TrainingModule") + migrated = True + + # M6: Drop category column (replaced by moduleType) + if _tableExists("TrainingModule") and _columnExists("TrainingModule", "category"): + cur.execute('ALTER TABLE "TrainingModule" DROP COLUMN "category"') + logger.info("Migration M6: Dropped category column from TrainingModule") + migrated = True + + # M7: Convert goals from JSON array to plain text + if _tableExists("TrainingModule") and _columnExists("TrainingModule", "goals"): + cur.execute(""" + UPDATE "TrainingModule" + SET "goals" = subq.plainText + FROM ( + SELECT id, + string_agg(elem->>'text', E'\\n') AS plainText + FROM "TrainingModule", + LATERAL jsonb_array_elements("goals"::jsonb) AS elem + WHERE "goals" IS NOT NULL + AND "goals" LIKE '[%' + GROUP BY id + ) subq + WHERE "TrainingModule".id = subq.id + """) + rowCount = cur.rowcount + if rowCount > 0: + logger.info(f"Migration M7: Converted {rowCount} goals fields from JSON to plain text") + migrated = True + + # M8: Create ModulePersonaMapping table + if not _tableExists("ModulePersonaMapping"): + cur.execute(""" + CREATE TABLE "ModulePersonaMapping" ( + id TEXT PRIMARY KEY, + "moduleId" TEXT NOT NULL, + "personaId" TEXT NOT NULL, + "instanceId" TEXT NOT NULL, + "createdAt" TEXT, + "updatedAt" TEXT, + UNIQUE("moduleId", "personaId") + ) + """) + cur.execute('CREATE INDEX IF NOT EXISTS idx_mpm_module ON "ModulePersonaMapping" ("moduleId")') + cur.execute('CREATE INDEX IF NOT EXISTS idx_mpm_persona ON "ModulePersonaMapping" ("personaId")') + logger.info("Migration M8: Created ModulePersonaMapping table") + migrated = True + + if migrated: + conn.commit() + logger.info("CommCoach DB migrations committed") + else: + conn.rollback() + + cur.close() + conn.close() + + except ImportError: + logger.debug("psycopg2 not available, skipping CommCoach DB migrations") + except Exception as e: + logger.warning(f"CommCoach DB migration failed (non-fatal): {e}") + + def _seedBuiltinPersonas(): """Seed builtin roleplay personas into the database.""" try: diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index c308684a..45075ae9 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -2,7 +2,7 @@ # All rights reserved. """ CommCoach routes for the backend API. -Implements coaching context management, session streaming, tasks, and dashboard. +Implements training module management, session streaming, tasks, and dashboard. """ import logging @@ -23,14 +23,14 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface from . import interfaceFeatureCommcoach as interfaceDb from .datamodelCommcoach import ( - CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus, + TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus, CoachingMessage, CoachingMessageRole, CoachingMessageContentType, CoachingTask, CoachingTaskStatus, - CoachingPersona, CoachingBadge, - CreateContextRequest, UpdateContextRequest, + CoachingPersona, CoachingBadge, ModulePersonaMapping, + CreateModuleRequest, UpdateModuleRequest, SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest, UpdateProfileRequest, - StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, + StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest, ) from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents from modules.shared.i18nRegistry import apiRouteContext @@ -91,204 +91,200 @@ def _validateOwnership(record: dict, context: RequestContext, fieldName: str = " # ========================================================================= -# Context Endpoints +# Module Endpoints (formerly Context) # ========================================================================= -@router.get("/{instanceId}/contexts") +@router.get("/{instanceId}/modules") @limiter.limit("60/minute") -async def listContexts( +async def listModules( request: Request, instanceId: str, includeArchived: bool = False, context: RequestContext = Depends(getRequestContext), ): - """List all coaching contexts for the current user.""" + """List all training modules for the current user.""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - contexts = interface.getContexts(instanceId, userId, includeArchived=includeArchived) - return {"contexts": contexts} + modules = interface.getModules(instanceId, userId, includeArchived=includeArchived) + return {"modules": modules} -@router.post("/{instanceId}/contexts") +@router.post("/{instanceId}/modules") @limiter.limit("20/minute") -async def createContext( +async def createModule( request: Request, instanceId: str, - body: CreateContextRequest, + body: CreateModuleRequest, context: RequestContext = Depends(getRequestContext), ): - """Create a new coaching context/dossier.""" + """Create a new training module.""" mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - goalsJson = None - if body.goals: - import uuid as _uuid - goalsList = [{"id": str(_uuid.uuid4()), "text": g, "status": "open", "createdAt": ""} for g in body.goals] - goalsJson = json.dumps(goalsList) - - contextData = CoachingContext( + moduleData = TrainingModule( userId=userId, mandateId=mandateId, instanceId=instanceId, title=body.title, description=body.description, - category=body.category, - goals=goalsJson, + moduleType=body.moduleType, + goals=body.goals, + personaId=body.personaId, + kpiTargets=body.kpiTargets, ).model_dump() - created = interface.createContext(contextData) - logger.info(f"CommCoach context created: {created.get('id')} for user {userId}") - _audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}") - return {"context": created} + created = interface.createModule(moduleData) + logger.info(f"CommCoach module created: {created.get('id')} for user {userId}") + _audit(context, "commcoach.module.created", "TrainingModule", created.get("id"), f"Title: {body.title}") + return {"module": created} -@router.get("/{instanceId}/contexts/{contextId}") +@router.get("/{instanceId}/modules/{moduleId}") @limiter.limit("60/minute") -async def getContext( +async def getModuleDetail( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): - """Get a coaching context with tasks and score summary.""" + """Get a training module with tasks and score summary.""" _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - tasks = interface.getTasks(contextId, userId) - scores = interface.getScores(contextId, userId) - sessions = interface.getSessions(contextId, userId) + tasks = interface.getTasks(moduleId, userId) + scores = interface.getScores(moduleId, userId) + sessions = interface.getSessions(moduleId, userId) return { - "context": ctx, + "module": mod, "tasks": tasks, "scores": scores, "sessions": sessions, } -@router.put("/{instanceId}/contexts/{contextId}") +@router.put("/{instanceId}/modules/{moduleId}") @limiter.limit("30/minute") -async def updateContext( +async def updateModuleFields( request: Request, instanceId: str, - contextId: str, - body: UpdateContextRequest, + moduleId: str, + body: UpdateModuleRequest, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) updates = body.model_dump(exclude_none=True) - updated = interface.updateContext(contextId, updates) - return {"context": updated} + updated = interface.updateModule(moduleId, updates) + return {"module": updated} -@router.delete("/{instanceId}/contexts/{contextId}") +@router.delete("/{instanceId}/modules/{moduleId}") @limiter.limit("10/minute") -async def deleteContext( +async def deleteModuleAndData( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - interface.deleteContext(contextId) + interface.deleteModule(moduleId) return {"deleted": True} -@router.post("/{instanceId}/contexts/{contextId}/archive") +@router.post("/{instanceId}/modules/{moduleId}/archive") @limiter.limit("10/minute") -async def archiveContext( +async def archiveModule( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value}) - _audit(context, "commcoach.context.archived", "CoachingContext", contextId) - return {"context": updated} + updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ARCHIVED.value}) + _audit(context, "commcoach.module.archived", "TrainingModule", moduleId) + return {"module": updated} -@router.post("/{instanceId}/contexts/{contextId}/activate") +@router.post("/{instanceId}/modules/{moduleId}/activate") @limiter.limit("10/minute") -async def activateContext( +async def activateModule( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value}) - return {"context": updated} + updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ACTIVE.value}) + return {"module": updated} # ========================================================================= # Session Endpoints # ========================================================================= -@router.get("/{instanceId}/contexts/{contextId}/sessions") +@router.get("/{instanceId}/modules/{moduleId}/sessions") @limiter.limit("60/minute") async def listSessions( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - sessions = interface.getSessions(contextId, userId) + sessions = interface.getSessions(moduleId, userId) return {"sessions": sessions} -@router.post("/{instanceId}/contexts/{contextId}/sessions/start") +@router.post("/{instanceId}/modules/{moduleId}/sessions/start") @limiter.limit("10/minute") async def startSession( request: Request, instanceId: str, - contextId: str, + moduleId: str, personaId: Optional[str] = None, context: RequestContext = Depends(getRequestContext), ): @@ -297,22 +293,22 @@ async def startSession( interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - activeSession = interface.getActiveSession(contextId, userId) + activeSession = interface.getActiveSession(moduleId, userId) if activeSession: sessionId = activeSession.get("id") messages = interface.getMessages(sessionId) async def _resumedEventGenerator(): service = CommcoachService(context.user, mandateId, instanceId) - greetingText = await service.generateResumeGreeting(sessionId, contextId, messages, interface) + greetingText = await service.generateResumeGreeting(sessionId, moduleId, messages, interface) assistantMsg = CoachingMessage( sessionId=sessionId, - contextId=contextId, + moduleId=moduleId, userId=userId, role=CoachingMessageRole.ASSISTANT, content=greetingText, @@ -323,7 +319,7 @@ async def startSession( greetingForFrontend = { "id": createdGreeting.get("id"), "sessionId": sessionId, - "contextId": contextId, + "moduleId": moduleId, "role": "assistant", "content": greetingText, "contentType": "text", @@ -365,7 +361,7 @@ async def startSession( ) sessionData = CoachingSession( - contextId=contextId, + moduleId=moduleId, userId=userId, mandateId=mandateId, instanceId=instanceId, @@ -378,7 +374,7 @@ async def startSession( await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False}) service = CommcoachService(context.user, mandateId, instanceId) - asyncio.create_task(service.processSessionOpening(sessionId, contextId, interface)) + asyncio.create_task(service.processSessionOpening(sessionId, moduleId, interface)) async def _newSessionEventGenerator(): from modules.shared.timeUtils import getIsoTimestamp @@ -399,8 +395,8 @@ async def startSession( except asyncio.CancelledError: pass - logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}") - _audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}") + logger.info(f"CommCoach session started (streaming): {sessionId} for module {moduleId}") + _audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Module: {moduleId}") return StreamingResponse( _newSessionEventGenerator(), media_type="text/event-stream", @@ -504,7 +500,7 @@ async def sendMessageStream( if session.get("status") != CoachingSessionStatus.ACTIVE.value: raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active")) - contextId = session.get("contextId") + moduleId = session.get("moduleId") service = CommcoachService(context.user, mandateId, instanceId) existingTask = _activeProcessTasks.get(sessionId) @@ -517,7 +513,7 @@ async def sendMessageStream( task = asyncio.create_task( service.processMessage( - sessionId, contextId, body.content, interface, + sessionId, moduleId, body.content, interface, fileIds=body.fileIds, dataSourceIds=body.dataSourceIds, featureDataSourceIds=body.featureDataSourceIds, @@ -587,11 +583,11 @@ async def sendAudioStream( from .serviceCommcoach import getUserVoicePrefs language, _ = getUserVoicePrefs(str(context.user.id), mandateId) - contextId = session.get("contextId") + moduleId = session.get("moduleId") service = CommcoachService(context.user, mandateId, instanceId) asyncio.create_task( - service.processAudioMessage(sessionId, contextId, audioBody, language, interface) + service.processAudioMessage(sessionId, moduleId, audioBody, language, interface) ) async def _eventGenerator(): @@ -680,27 +676,27 @@ async def streamSession( # Task Endpoints # ========================================================================= -@router.get("/{instanceId}/contexts/{contextId}/tasks") +@router.get("/{instanceId}/modules/{moduleId}/tasks") @limiter.limit("60/minute") async def listTasks( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - tasks = interface.getTasks(contextId, userId) + tasks = interface.getTasks(moduleId, userId) return {"tasks": tasks} -@router.post("/{instanceId}/contexts/{contextId}/tasks") +@router.post("/{instanceId}/modules/{moduleId}/tasks") @limiter.limit("30/minute") async def createTask( request: Request, instanceId: str, - contextId: str, + moduleId: str, body: CreateTaskRequest, context: RequestContext = Depends(getRequestContext), ): @@ -708,13 +704,13 @@ async def createTask( interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) taskData = CoachingTask( - contextId=contextId, + moduleId=moduleId, userId=userId, mandateId=mandateId, title=body.title, @@ -853,12 +849,12 @@ async def updateProfile( # Export Endpoints (Iteration 2) # ========================================================================= -@router.get("/{instanceId}/contexts/{contextId}/export") +@router.get("/{instanceId}/modules/{moduleId}/export") @limiter.limit("10/minute") async def exportDossier( request: Request, instanceId: str, - contextId: str, + moduleId: str, format: str = "md", context: RequestContext = Depends(getRequestContext), ): @@ -867,26 +863,26 @@ async def exportDossier( interface = _getInterface(context, instanceId) userId = str(context.user.id) - ctx = interface.getContext(contextId) - if not ctx: - raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) - _validateOwnership(ctx, context) + mod = interface.getModule(moduleId) + if not mod: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + _validateOwnership(mod, context) - tasks = interface.getTasks(contextId, userId) - scores = interface.getScores(contextId, userId) - sessions = interface.getSessions(contextId, userId) + tasks = interface.getTasks(moduleId, userId) + scores = interface.getScores(moduleId, userId) + sessions = interface.getSessions(moduleId, userId) from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf - _audit(context, "commcoach.export.requested", "CoachingContext", contextId, f"format={format}") + _audit(context, "commcoach.export.requested", "TrainingModule", moduleId, f"format={format}") if format == "pdf": - pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores) + pdfBytes = await renderDossierPdf(mod, sessions, tasks, scores) return Response(content=pdfBytes, media_type="application/pdf", - headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'}) + headers={"Content-Disposition": f'attachment; filename="dossier_{moduleId[:8]}.pdf"'}) - md = buildDossierMarkdown(ctx, sessions, tasks, scores) + md = buildDossierMarkdown(mod, sessions, tasks, scores) return Response(content=md, media_type="text/markdown", - headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.md"'}) + headers={"Content-Disposition": f'attachment; filename="dossier_{moduleId[:8]}.md"'}) @router.get("/{instanceId}/sessions/{sessionId}/export") @@ -907,11 +903,11 @@ async def exportSession( raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) - contextId = session.get("contextId") + moduleId = session.get("moduleId") userId = str(context.user.id) messages = interface.getMessages(sessionId) - tasks = interface.getTasks(contextId, userId) if contextId else [] - scores = interface.getScores(contextId, userId) if contextId else [] + tasks = interface.getTasks(moduleId, userId) if moduleId else [] + scores = interface.getScores(moduleId, userId) if moduleId else [] from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf _audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}") @@ -935,13 +931,47 @@ async def exportSession( async def listPersonas( request: Request, instanceId: str, + pagination: Optional[str] = Query(None), + mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"), + column: Optional[str] = Query(None, description="Column key for mode=filterValues"), context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - userId = str(context.user.id) - personas = interface.getPersonas(userId, instanceId) - return {"personas": personas} + allPersonas = interface.getAllPersonas(instanceId) + + if mode == "filterValues": + from modules.routes.routeHelpers 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 + 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) + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) + filtered = applyFiltersAndSort(allPersonas, paginationParams) + pageItems, totalItems = paginateInMemory(filtered, paginationParams) + import math + return { + "items": pageItems, + "pagination": PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0, + sort=[s.model_dump() for s in paginationParams.sort] if paginationParams.sort else [], + filters=paginationParams.filters, + ).model_dump(), + } + + return {"items": allPersonas, "pagination": None} @router.post("/{instanceId}/personas") @@ -1017,6 +1047,43 @@ async def deletePersonaRoute( return {"deleted": True} +# ========================================================================= +# Module-Persona Mapping Endpoints +# ========================================================================= + +@router.get("/{instanceId}/modules/{moduleId}/personas") +@limiter.limit("60/minute") +async def getModulePersonas( + request: Request, + instanceId: str, + moduleId: str, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + mappings = interface.getModulePersonas(moduleId) + personaIds = [m["personaId"] for m in mappings] + return {"personaIds": personaIds} + + +@router.put("/{instanceId}/modules/{moduleId}/personas") +@limiter.limit("20/minute") +async def setModulePersonas( + request: Request, + instanceId: str, + moduleId: str, + body: SetModulePersonasRequest, + context: RequestContext = Depends(getRequestContext), +): + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + module = interface.getModule(moduleId) + if not module: + raise HTTPException(status_code=404, detail=routeApiMsg("Module not found")) + interface.setModulePersonas(moduleId, body.personaIds, instanceId) + return {"personaIds": body.personaIds} + + # ========================================================================= # Badge + Score History Endpoints (Iteration 2) # ========================================================================= @@ -1035,16 +1102,46 @@ async def listBadges( return {"badges": badges} -@router.get("/{instanceId}/contexts/{contextId}/scores/history") +@router.get("/{instanceId}/modules/{moduleId}/scores/history") @limiter.limit("60/minute") async def getScoreHistory( request: Request, instanceId: str, - contextId: str, + moduleId: str, context: RequestContext = Depends(getRequestContext), ): _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) - history = interface.getScoreHistory(contextId, userId) + history = interface.getScoreHistory(moduleId, userId) return {"history": history} + + +# ========================================================================= +# Backward-Compatibility Redirects (old /contexts/ paths → /modules/) +# ========================================================================= + +@router.get("/{instanceId}/contexts") +async def _redirectListContexts(instanceId: str, request: Request): + qs = f"?{request.query_params}" if request.query_params else "" + return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules{qs}"}) + + +@router.post("/{instanceId}/contexts") +async def _redirectCreateContext(instanceId: str, request: Request): + return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules"}) + + +@router.get("/{instanceId}/contexts/{contextId}") +async def _redirectGetContext(instanceId: str, contextId: str, request: Request): + return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"}) + + +@router.put("/{instanceId}/contexts/{contextId}") +async def _redirectUpdateContext(instanceId: str, contextId: str, request: Request): + return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"}) + + +@router.delete("/{instanceId}/contexts/{contextId}") +async def _redirectDeleteContext(instanceId: str, contextId: str, request: Request): + return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"}) diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 4ebe84ff..821fb291 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -420,7 +420,7 @@ async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})") except Exception as e: - logger.warning(f"Failed to save document as FileItem: {e}") + logger.error(f"Failed to save document as FileItem: {e}", exc_info=True) @@ -483,12 +483,12 @@ def _loadDocumentContents(docIds: List[str], interface, mandateId: str = None, i content = "" try: from modules.datamodels.datamodelKnowledge import FileContentIndex - idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"fileId": fId}) + idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"id": fId}) if idxRecords: idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump() content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS] - except Exception: - pass + except Exception as e: + logger.warning(f"Failed to load FileContentIndex for {fId}: {e}") results.append({ "id": fId, "title": f.get("fileName") or f.get("name") or "Dokument", @@ -557,13 +557,13 @@ def _getDocumentSummaries(contextId: str, userId: str, interface, try: from modules.datamodels.datamodelKnowledge import FileContentIndex idxRecords = mgmtIf.db.getRecordset( - FileContentIndex, recordFilter={"fileId": fId} + FileContentIndex, recordFilter={"id": fId} ) if idxRecords: idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump() snippet = (idx.get("extractedText") or "")[:200] - except Exception: - pass + except Exception as e: + logger.warning(f"Failed to load FileContentIndex for {fId}: {e}") if snippet: summaries.append(f"[{name}] {snippet}...") else: @@ -748,7 +748,7 @@ class CommcoachService: # Store user message userMsg = CoachingMessage( sessionId=sessionId, - contextId=contextId, + moduleId=contextId, userId=self.userId, role=CoachingMessageRole.USER, content=userContent, @@ -764,7 +764,7 @@ class CommcoachService: }) # Build context - context = interface.getContext(contextId) + context = interface.getModule(contextId) if not context: logger.error(f"Context {contextId} not found") return createdUserMsg @@ -857,7 +857,7 @@ class CommcoachService: assistantMsg = CoachingMessage( sessionId=sessionId, - contextId=contextId, + moduleId=contextId, userId=self.userId, role=CoachingMessageRole.ASSISTANT, content=textContent, @@ -946,6 +946,8 @@ class CommcoachService: await emitSessionEvent(sessionId, "toolResult", event.data or {}) elif event.type == AgentEventTypeEnum.AGENT_PROGRESS: await emitSessionEvent(sessionId, "agentProgress", event.data or {}) + elif event.type == AgentEventTypeEnum.FILE_CREATED: + await emitSessionEvent(sessionId, "documentCreated", event.data or {}) elif event.type == AgentEventTypeEnum.ERROR: await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent error"}) @@ -958,7 +960,7 @@ class CommcoachService: """ await emitSessionEvent(sessionId, "status", {"label": "Coach bereitet sich vor..."}) - context = interface.getContext(contextId) + context = interface.getModule(contextId) if not context: logger.error(f"Context {contextId} not found") await emitSessionEvent(sessionId, "error", {"message": "Context not found"}) @@ -1024,7 +1026,7 @@ class CommcoachService: assistantMsg = CoachingMessage( sessionId=sessionId, - contextId=contextId, + moduleId=contextId, userId=self.userId, role=CoachingMessageRole.ASSISTANT, content=textContent, @@ -1046,7 +1048,7 @@ class CommcoachService: async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str: """Generate a follow-up greeting when user returns to an active session.""" - context = interface.getContext(contextId) + context = interface.getModule(contextId) if not context: raise ValueError(f"Context {contextId} not found for resume greeting") contextTitle = context.get("title", "Coaching") @@ -1100,8 +1102,10 @@ class CommcoachService: if not session: return {} - contextId = session.get("contextId") - context = interface.getContext(contextId) if contextId else None + contextId = session.get("moduleId") + if not contextId: + logger.error(f"completeSession: session {sessionId} has no moduleId") + context = interface.getModule(contextId) if contextId else None messages = interface.getMessages(sessionId) if len(messages) < 2: @@ -1156,7 +1160,7 @@ class CommcoachService: for taskData in extractedTasks[:3]: if isinstance(taskData, dict) and taskData.get("title"): newTask = CoachingTask( - contextId=contextId, + moduleId=contextId, sessionId=sessionId, userId=self.userId, mandateId=self.mandateId, @@ -1181,7 +1185,7 @@ class CommcoachService: for scoreData in scores: if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData: newScore = CoachingScore( - contextId=contextId, + moduleId=contextId, sessionId=sessionId, userId=self.userId, mandateId=self.mandateId, @@ -1213,7 +1217,7 @@ class CommcoachService: existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()}) await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId}) if contextId and existingInsights: - interface.updateContext(contextId, {"insights": json.dumps(existingInsights[-10:])}) + interface.updateModule(contextId, {"insights": json.dumps(existingInsights[-10:])}) except Exception as e: logger.warning(f"Insight generation failed: {e}") @@ -1280,7 +1284,7 @@ class CommcoachService: if contextId: allSessions = interface.getSessions(contextId, self.userId) completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value]) - interface.updateContext(contextId, { + interface.updateModule(contextId, { "sessionCount": completedCount, "lastSessionAt": getUtcTimestamp(), }) @@ -1429,7 +1433,7 @@ class CommcoachService: "sessionSummaries": [], } - ctx = interface.getContext(contextId) + ctx = interface.getModule(contextId) rollingOverview = ctx.get("rollingOverview") if ctx else None rollingUpTo = ctx.get("rollingOverviewUpToSessionCount") if ctx else None @@ -1506,7 +1510,7 @@ class CommcoachService: ) if overviewResponse and overviewResponse.errorCount == 0 and overviewResponse.content: newOverview = overviewResponse.content.strip() - interface.updateContext(contextId, { + interface.updateModule(contextId, { "rollingOverview": newOverview, "rollingOverviewUpToSessionCount": len(completedSessions), }) diff --git a/modules/features/commcoach/serviceCommcoachGamification.py b/modules/features/commcoach/serviceCommcoachGamification.py index badf9761..331dd9b1 100644 --- a/modules/features/commcoach/serviceCommcoachGamification.py +++ b/modules/features/commcoach/serviceCommcoachGamification.py @@ -143,7 +143,7 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId badgesToCheck.append(("roleplay_first", True)) try: - from .datamodelCommcoach import CoachingContextStatus + from .datamodelCommcoach import TrainingModuleStatus allContexts = interface.db.getRecordset( interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues ) if False else [] diff --git a/modules/features/commcoach/serviceCommcoachPersonas.py b/modules/features/commcoach/serviceCommcoachPersonas.py index f5c8254e..867b51a0 100644 --- a/modules/features/commcoach/serviceCommcoachPersonas.py +++ b/modules/features/commcoach/serviceCommcoachPersonas.py @@ -146,6 +146,57 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [ "gender": "m", "category": "builtin", }, + # --- Fachpersonen / Therapeutische & rechtliche Gesprächspartner --- + { + "key": "couples_therapist_f", + "label": "Paartherapeutin", + "description": "Dr. Eva Roth, erfahrene Paartherapeutin. Empathisch, strukturiert, stellt gezielte Fragen zu " + "Beziehungsdynamiken. Spiegelt Gefühle und Muster, ohne Partei zu ergreifen. Arbeitet mit der " + "Gewaltfreien Kommunikation und systemischen Methoden. Fragt nach Bedürfnissen hinter Vorwürfen " + "und lenkt das Gespräch auf konkrete Verhaltensänderungen statt Schuldzuweisungen.", + "gender": "f", + "category": "builtin", + }, + { + "key": "psychologist_m", + "label": "Psychologe", + "description": "Dr. Markus Frei, klinischer Psychologe mit Schwerpunkt Stressbewältigung und Burnout-Prävention. " + "Ruhig, geduldig, stellt offene Fragen zur Selbstreflexion. Erkennt Denkmuster und benennt sie " + "behutsam. Arbeitet lösungsorientiert und hilft bei der Identifikation von Stressoren, Ressourcen " + "und Bewältigungsstrategien. Drängt nicht, lässt Raum für Stille und Nachdenken.", + "gender": "m", + "category": "builtin", + }, + { + "key": "lawyer_m", + "label": "Rechtsanwalt", + "description": "lic. iur. Daniel Brandt, Wirtschaftsanwalt mit Fokus auf Vertragsrecht und Arbeitsrecht. Sachlich, " + "analytisch, prüft jede Aussage auf juristische Stichhaltigkeit. Fragt nach Fakten, Fristen und " + "Beweislage. Weist auf Risiken und Haftungsfragen hin. Formuliert präzise und erwartet dasselbe " + "vom Gegenüber. Kann unangenehme rechtliche Realitäten nüchtern kommunizieren.", + "gender": "m", + "category": "builtin", + }, + { + "key": "mediator_f", + "label": "Mediatorin", + "description": "Sabine Lang, zertifizierte Wirtschaftsmediatorin. Strikt neutral, strukturiert den Dialog zwischen " + "Konfliktparteien. Stellt sicher, dass beide Seiten gehört werden. Arbeitet mit Ich-Botschaften und " + "Interessenklärung statt Positionsverhandlung. Unterbricht respektvoll bei Eskalation und lenkt " + "zurück auf Sachebene. Ziel ist immer eine tragfähige Vereinbarung, nicht Recht oder Unrecht.", + "gender": "f", + "category": "builtin", + }, + { + "key": "hr_manager_f", + "label": "HR-Managerin", + "description": "Kathrin Vogt, Head of HR in einem Konzern. Kennt Arbeitsrecht, Feedbackkultur und Change-Prozesse. " + "Spricht diplomatisch aber klar. Achtet auf Compliance und Gleichbehandlung. Erwartet strukturierte " + "Argumentation bei Personalentscheiden. Reagiert sensibel auf Diskriminierungs- oder Mobbingthemen. " + "Kann sowohl Arbeitgeber- als auch Arbeitnehmerperspektive einnehmen.", + "gender": "f", + "category": "builtin", + }, ] diff --git a/modules/features/commcoach/serviceCommcoachScheduler.py b/modules/features/commcoach/serviceCommcoachScheduler.py index 00bc3b1e..72e253d6 100644 --- a/modules/features/commcoach/serviceCommcoachScheduler.py +++ b/modules/features/commcoach/serviceCommcoachScheduler.py @@ -62,7 +62,7 @@ async def _runDailyReminders(): try: from modules.shared.configuration import APP_CONFIG from modules.connectors.connectorDbPostgre import DatabaseConnector - from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus + from .datamodelCommcoach import CoachingUserProfile, TrainingModuleStatus from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName @@ -94,10 +94,10 @@ async def _runDailyReminders(): continue # Check if user has active contexts - from .datamodelCommcoach import CoachingContext - contexts = db.getRecordset(CoachingContext, recordFilter={ + from .datamodelCommcoach import TrainingModule + contexts = db.getRecordset(TrainingModule, recordFilter={ "userId": userId, - "status": CoachingContextStatus.ACTIVE.value, + "status": TrainingModuleStatus.ACTIVE.value, }) if not contexts: continue diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py index f7d12fda..8c8d61e7 100644 --- a/modules/features/teamsbot/datamodelTeamsbot.py +++ b/modules/features/teamsbot/datamodelTeamsbot.py @@ -79,15 +79,47 @@ class TeamsbotTransferMode(str, Enum): AUTO = "auto" # Automatic: anonymous → audio, authenticated → caption +class TeamsbotSeriesType(str, Enum): + """Type of meeting series.""" + WEEKLY = "weekly" + BIWEEKLY = "biweekly" + MONTHLY = "monthly" + ADHOC = "adhoc" + PROJECT = "project" + + +class TeamsbotModuleStatus(str, Enum): + """Status of a meeting module.""" + ACTIVE = "active" + ARCHIVED = "archived" + COMPLETED = "completed" + + # ============================================================================ # Database Models (stored in PostgreSQL) # ============================================================================ +class TeamsbotMeetingModule(PowerOnModel): + """A meeting module groups related sessions (e.g. 'Weekly Standup').""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Module ID") + instanceId: str = Field(description="Feature instance ID (FK)") + mandateId: str = Field(description="Mandate ID (FK)") + ownerUserId: str = Field(description="Owner user ID") + title: str = Field(description="Module title, e.g. 'Weekly Standup'") + seriesType: TeamsbotSeriesType = Field(default=TeamsbotSeriesType.ADHOC) + defaultBotId: Optional[str] = Field(default=None, description="FK to TeamsbotSystemBot") + defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts") + goals: Optional[str] = Field(default=None, description="Free-text goals") + kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets") + status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE) + + class TeamsbotSession(PowerOnModel): """A Teams Bot meeting session.""" id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID") instanceId: str = Field(description="Feature instance ID (FK)") mandateId: str = Field(description="Mandate ID (FK)") + moduleId: Optional[str] = Field(default=None, description="FK to TeamsbotMeetingModule (nullable during transition)") meetingLink: str = Field(description="Teams meeting join link") botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting") status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status") @@ -237,6 +269,27 @@ class TeamsbotSessionResponse(BaseModel): botResponses: Optional[List[TeamsbotBotResponse]] = Field(default=None, description="Bot responses (if requested)") +class CreateMeetingModuleRequest(BaseModel): + """Request to create a new meeting module.""" + title: str + seriesType: Optional[TeamsbotSeriesType] = TeamsbotSeriesType.ADHOC + defaultBotId: Optional[str] = None + defaultDirectorPrompts: Optional[str] = None + goals: Optional[str] = None + kpiTargets: Optional[str] = None + + +class UpdateMeetingModuleRequest(BaseModel): + """Request to update an existing meeting module.""" + title: Optional[str] = None + seriesType: Optional[TeamsbotSeriesType] = None + defaultBotId: Optional[str] = None + defaultDirectorPrompts: Optional[str] = None + goals: Optional[str] = None + kpiTargets: Optional[str] = None + status: Optional[TeamsbotModuleStatus] = None + + class TeamsbotConfigUpdateRequest(BaseModel): """Request to update teamsbot configuration.""" botName: Optional[str] = None diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index a7dedd6e..8491b3b9 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -24,6 +24,7 @@ from .datamodelTeamsbot import ( TeamsbotDirectorPrompt, TeamsbotDirectorPromptStatus, TeamsbotDirectorPromptMode, + TeamsbotMeetingModule, ) logger = logging.getLogger(__name__) @@ -330,6 +331,36 @@ class TeamsbotObjects: count += 1 return count + # ========================================================================= + # Meeting Modules + # ========================================================================= + + def getModules(self, instanceId: str) -> List[Dict[str, Any]]: + """Get all meeting modules for a feature instance.""" + records = self.db.getRecordset(TeamsbotMeetingModule, recordFilter={"instanceId": instanceId}) + records.sort(key=lambda r: r.get("sysCreatedAt") or "", reverse=True) + return records + + def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]: + """Get a single meeting module by ID.""" + records = self.db.getRecordset(TeamsbotMeetingModule, recordFilter={"id": moduleId}) + return records[0] if records else None + + def createModule(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Create a new meeting module.""" + return self.db.recordCreate(TeamsbotMeetingModule, data) + + def updateModule(self, moduleId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Update an existing meeting module.""" + return self.db.recordModify(TeamsbotMeetingModule, moduleId, updates) + + def deleteModule(self, moduleId: str) -> bool: + """Delete a meeting module. Unlinks all sessions first.""" + sessions = self.db.getRecordset(TeamsbotSession, recordFilter={"moduleId": moduleId}) + for session in sessions: + self.db.recordModify(TeamsbotSession, session["id"], {"moduleId": None}) + return self.db.recordDelete(TeamsbotMeetingModule, moduleId) + # ========================================================================= # Stats / Aggregation # ========================================================================= @@ -338,14 +369,23 @@ class TeamsbotObjects: """Get aggregated statistics for a session.""" transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId}) responses = self.db.getRecordset(TeamsbotBotResponse, recordFilter={"sessionId": sessionId}) - + prompts = self.db.getRecordset(TeamsbotDirectorPrompt, recordFilter={"sessionId": sessionId}) + totalCost = sum(r.get("priceCHF", 0) for r in responses) totalProcessingTime = sum(r.get("processingTime", 0) for r in responses) - + speakers = list(set(t.get("speaker") for t in transcripts if t.get("speaker"))) + + firstTimestamp = min((t.get("timestamp") or 0 for t in transcripts), default=0) + lastTimestamp = max((t.get("timestamp") or 0 for t in transcripts), default=0) + durationSeconds = round(lastTimestamp - firstTimestamp, 1) if firstTimestamp and lastTimestamp else 0 + return { "transcriptSegments": len(transcripts), "botResponses": len(responses), + "directorPrompts": len(prompts), "totalCostCHF": round(totalCost, 4), "totalProcessingTime": round(totalProcessingTime, 2), - "speakers": list(set(t.get("speaker") for t in transcripts if t.get("speaker"))), + "speakers": speakers, + "speakerCount": len(speakers), + "durationSeconds": durationSeconds, } diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py index c2c271e0..66bc9247 100644 --- a/modules/features/teamsbot/mainTeamsbot.py +++ b/modules/features/teamsbot/mainTeamsbot.py @@ -24,6 +24,16 @@ UI_OBJECTS = [ "label": t("Dashboard", context="UI"), "meta": {"area": "dashboard"} }, + { + "objectKey": "ui.feature.teamsbot.assistant", + "label": t("Assistent", context="UI"), + "meta": {"area": "assistant"} + }, + { + "objectKey": "ui.feature.teamsbot.modules", + "label": t("Module", context="UI"), + "meta": {"area": "modules"} + }, { "objectKey": "ui.feature.teamsbot.sessions", "label": t("Sitzungen", context="UI"), @@ -38,13 +48,24 @@ UI_OBJECTS = [ # DATA Objects for RBAC catalog (tables/entities) DATA_OBJECTS = [ + { + "objectKey": "data.feature.teamsbot.TeamsbotMeetingModule", + "label": t("Meeting-Modul", context="UI"), + "meta": { + "table": "TeamsbotMeetingModule", + "fields": ["id", "title", "seriesType", "status", "ownerUserId"], + "isParent": True, + "displayFields": ["title", "seriesType", "status"], + } + }, { "objectKey": "data.feature.teamsbot.TeamsbotSession", "label": t("Sitzung", context="UI"), "meta": { "table": "TeamsbotSession", "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"], - "isParent": True, + "parentTable": "TeamsbotMeetingModule", + "parentKey": "moduleId", "displayFields": ["botName", "status", "startedAt"], } }, @@ -97,6 +118,16 @@ RESOURCE_OBJECTS = [ "label": t("Konfiguration bearbeiten", context="UI"), "meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True} }, + { + "objectKey": "resource.feature.teamsbot.module.create", + "label": t("Meeting-Modul erstellen", context="UI"), + "meta": {"endpoint": "/api/teamsbot/{instanceId}/modules", "method": "POST"} + }, + { + "objectKey": "resource.feature.teamsbot.module.delete", + "label": t("Meeting-Modul loeschen", context="UI"), + "meta": {"endpoint": "/api/teamsbot/{instanceId}/modules/{moduleId}", "method": "DELETE"} + }, ] # Template roles for this feature with AccessRules @@ -114,6 +145,8 @@ TEMPLATE_ROLES = [ {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.delete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.teamsbot.module.delete", "view": True}, ] }, { @@ -121,6 +154,8 @@ TEMPLATE_ROLES = [ "description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True}, + {"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, ], @@ -130,12 +165,16 @@ TEMPLATE_ROLES = [ "description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen", "accessRules": [ {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True}, + {"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, + {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotMeetingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True}, ], }, ] @@ -198,6 +237,7 @@ def registerFeature(catalogService) -> bool: meta=dataObj.get("meta") ) + _runMigrations() _syncTemplateRolesToDb() logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects") @@ -208,6 +248,95 @@ def registerFeature(catalogService) -> bool: return False +def _runMigrations(): + """Idempotent DB migrations for TeamsBot feature. + Runs on every bootstrap; each step checks preconditions before executing. + The TeamsbotMeetingModule table and TeamsbotSession.moduleId column are + auto-created by the DB connector from the Pydantic model. This migration + handles data backfill: creating default Adhoc modules for existing sessions. + """ + try: + from .interfaceFeatureTeamsbot import teamsbotDatabase + from .datamodelTeamsbot import TeamsbotMeetingModule, TeamsbotSession + 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"), + database=teamsbotDatabase, + user=APP_CONFIG.get("DB_USER"), + password=APP_CONFIG.get("DB_PASSWORD_SECRET"), + port=int(APP_CONFIG.get("DB_PORT", 5432)), + cursor_factory=RealDictCursor, + ) + conn.autocommit = False + cur = conn.cursor() + + def _tableExists(name): + cur.execute( + "SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'", + (name,), + ) + return cur.fetchone() is not None + + def _columnExists(table, column): + cur.execute( + "SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'", + (table, column), + ) + return cur.fetchone() is not None + + migrated = False + + # M1: Create default Adhoc modules for orphaned sessions + # (only runs if TeamsbotSession table exists with moduleId column + # and there are sessions without a moduleId) + if _tableExists("TeamsbotSession") and _columnExists("TeamsbotSession", "moduleId"): + cur.execute(""" + SELECT DISTINCT "instanceId", "mandateId" + FROM "TeamsbotSession" + WHERE "moduleId" IS NULL AND "instanceId" IS NOT NULL + """) + orphanGroups = cur.fetchall() + for group in orphanGroups: + instId = group["instanceId"] + mandId = group["mandateId"] + if not instId: + continue + + adhocId = str(uuid.uuid4()) + import time as _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) + """, (adhocId, instId, mandId, now)) + cur.execute(""" + UPDATE "TeamsbotSession" + SET "moduleId" = %s + WHERE "instanceId" = %s AND "moduleId" IS NULL + """, (adhocId, instId)) + sessionCount = cur.rowcount + logger.info(f"Migration M1: Created Adhoc module for instanceId={instId}, assigned {sessionCount} sessions") + migrated = True + + if migrated: + conn.commit() + logger.info("TeamsBot DB migrations committed") + else: + conn.rollback() + + cur.close() + conn.close() + + except ImportError: + logger.debug("psycopg2 not available, skipping TeamsBot DB migrations") + except Exception as e: + logger.warning(f"TeamsBot DB migration failed (non-fatal): {e}") + + def _syncTemplateRolesToDb() -> int: """Sync template roles and their AccessRules to the database.""" try: diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index 3368f9fc..ab42db22 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -39,6 +39,9 @@ from .datamodelTeamsbot import ( TeamsbotDirectorPromptCreateRequest, TeamsbotDirectorPromptMode, TeamsbotDirectorPromptStatus, + TeamsbotMeetingModule, + CreateMeetingModuleRequest, + UpdateMeetingModuleRequest, DIRECTOR_PROMPT_FILE_LIMIT, DIRECTOR_PROMPT_TEXT_LIMIT, ) @@ -167,6 +170,100 @@ def _getInstanceConfig(instanceId: str) -> TeamsbotConfig: return TeamsbotConfig() +# ========================================================================= +# Meeting Module Endpoints +# ========================================================================= + +@router.get("/{instanceId}/modules") +@limiter.limit("60/minute") +async def listModules( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext), +): + """List all meeting modules for a feature instance.""" + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + modules = interface.getModules(instanceId) + return {"modules": modules} + + +@router.post("/{instanceId}/modules") +@limiter.limit("30/minute") +async def createModule( + request: Request, + instanceId: str, + body: CreateMeetingModuleRequest, + context: RequestContext = Depends(getRequestContext), +): + """Create a new meeting module.""" + interface = _getInterface(context, instanceId) + mandateId = _validateInstanceAccess(instanceId, context) + data = body.model_dump(exclude_none=True) + data["instanceId"] = instanceId + data["mandateId"] = mandateId + data["ownerUserId"] = str(context.user.id) + module = interface.createModule(data) + return {"module": module} + + +@router.get("/{instanceId}/modules/{moduleId}") +@limiter.limit("60/minute") +async def getModuleDetail( + request: Request, + instanceId: str, + moduleId: str, + context: RequestContext = Depends(getRequestContext), +): + """Get a single module with its sessions.""" + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + module = interface.getModule(moduleId) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + sessions = interface.getSessions(instanceId) + moduleSessions = [s for s in sessions if s.get("moduleId") == moduleId] + return {"module": module, "sessions": moduleSessions} + + +@router.put("/{instanceId}/modules/{moduleId}") +@limiter.limit("30/minute") +async def updateModule( + request: Request, + instanceId: str, + moduleId: str, + body: UpdateMeetingModuleRequest, + context: RequestContext = Depends(getRequestContext), +): + """Update an existing meeting module.""" + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + module = interface.getModule(moduleId) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + updates = body.model_dump(exclude_none=True) + updated = interface.updateModule(moduleId, updates) + return {"module": updated} + + +@router.delete("/{instanceId}/modules/{moduleId}") +@limiter.limit("10/minute") +async def deleteModule( + request: Request, + instanceId: str, + moduleId: str, + context: RequestContext = Depends(getRequestContext), +): + """Delete a meeting module and unlink its sessions.""" + _validateInstanceAccess(instanceId, context) + interface = _getInterface(context, instanceId) + module = interface.getModule(moduleId) + if not module: + raise HTTPException(status_code=404, detail="Module not found") + interface.deleteModule(moduleId) + return {"success": True} + + # ========================================================================= # Session Endpoints # ========================================================================= @@ -385,8 +482,9 @@ async def streamSession( """Generate SSE events from the session event queue.""" from .service import sessionEvents - # Send initial session state - yield f"data: {json.dumps({'type': 'sessionState', 'data': session})}\n\n" + # Send initial session state with stats + stats = interface.getSessionStats(sessionId) + yield f"data: {json.dumps({'type': 'sessionState', 'data': session, 'stats': stats})}\n\n" # Send current bot WebSocket connection state so the operator UI can # render the live indicator without waiting for the next connect/disconnect. diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index 1d3939ac..fe0d6c34 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -3409,6 +3409,8 @@ class TeamsbotService: "status": "toolCall", "toolName": toolName, }) + elif event.type == AgentEventTypeEnum.FILE_CREATED: + await _emitSessionEvent(sessionId, "documentCreated", event.data or {}) elif event.type == AgentEventTypeEnum.FINAL: finalText = (event.content or "").strip() elif event.type == AgentEventTypeEnum.ERROR: diff --git a/modules/migration/seedData/ui_language_seed.json b/modules/migration/seedData/ui_language_seed.json index 2d2193c8..0f769074 100644 --- a/modules/migration/seedData/ui_language_seed.json +++ b/modules/migration/seedData/ui_language_seed.json @@ -658,6 +658,11 @@ "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", @@ -4046,6 +4051,11 @@ "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", @@ -7404,6 +7414,11 @@ "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", @@ -10617,6 +10632,11 @@ "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", diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py index 56ba791a..a23688e5 100644 --- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py +++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py @@ -203,16 +203,40 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser args["featureInstanceId"] = context["featureInstanceId"] 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())}" + chatSvc = getattr(services, "chat", None) if services else None + if chatSvc: + try: + chatSvc.progressLogStart(toolOpId, methodName.capitalize(), actionName, "Agent tool") + except Exception: + pass + args["parentOperationId"] = toolOpId + else: + toolOpId = None + chatSvc = None result = await actionExecutor.executeAction(methodName, actionName, args) - data = _formatActionResult(result, services, context) + if toolOpId and chatSvc: + try: + chatSvc.progressLogFinish(toolOpId, result.success) + except Exception: + pass + data, sideEvents = _formatActionResult(result, services, context) return ToolResult( toolCallId="", toolName=f"{methodName}_{actionName}", success=result.success, data=data, - error=result.error + error=result.error, + sideEvents=sideEvents or None, ) except Exception as e: + if toolOpId and chatSvc: + try: + chatSvc.progressLogFinish(toolOpId, False) + except Exception: + pass logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}") return ToolResult( toolCallId="", @@ -226,11 +250,12 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser _INLINE_CONTENT_LIMIT = 2000 -def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[str]: - """Save an ActionDocument with large content as a workspace file. +def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Save an ActionDocument as a workspace file. - Returns a formatted result line (with file id + docItem ref) or None - if persistence is not possible. + Handles both str and bytes documentData. + Returns a dict with 'line' (formatted result) and 'fileInfo' (for sideEvents), + or None if persistence is not possible. """ if not services: return None @@ -238,49 +263,77 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[st if not chatService: return None docData = getattr(doc, "documentData", None) - if not docData or not isinstance(docData, str): + if not docData: + return None + if isinstance(docData, bytes): + docBytes = docData + elif isinstance(docData, str): + docBytes = docData.encode("utf-8") + else: return None docName = getattr(doc, "documentName", "unnamed") - docBytes = docData.encode("utf-8") + docMime = getattr(doc, "mimeType", "application/octet-stream") try: fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName) - fiId = context.get("featureInstanceId") or getattr(services, "featureInstanceId", "") - if fiId: - chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId}) from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _attachFileAsChatDocument, _formatToolFileResult, _getOrCreateTempFolder, ) + + updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) if tempFolderId: - chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId}) + updateFields["folderId"] = tempFolderId + fiId = context.get("featureInstanceId") or getattr(services, "featureInstanceId", "") + if fiId: + updateFields["featureInstanceId"] = fiId + updateFields["scope"] = "featureInstance" + mandateId = context.get("mandateId") or getattr(services, "mandateId", "") + if mandateId: + updateFields["mandateId"] = mandateId + if updateFields: + logger.debug("_persistLargeDocument: updating file %s with %s", fileItem.id, updateFields) + chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields) + else: + logger.warning("_persistLargeDocument: no updateFields for file %s (tempFolderId=%s, fiId=%s)", fileItem.id, tempFolderId, fiId) chatDocId = _attachFileAsChatDocument( services, fileItem, label=f"action_doc:{docName}", userMessage=f"Action document: {docName}", ) - return _formatToolFileResult( + line = _formatToolFileResult( fileItem=fileItem, chatDocId=chatDocId, actionLabel="Produced", extraInfo="Use readFile to read the content.", ) + return { + "line": line, + "fileInfo": { + "fileId": fileItem.id, + "fileName": docName, + "mimeType": docMime, + "fileSize": len(docBytes), + }, + } except Exception as e: - logger.warning(f"_persistLargeDocument failed for {docName}: {e}") + logger.error(f"_persistLargeDocument failed for {docName}: {e}", exc_info=True) return None -def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] = None) -> str: +def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] = None): """Format an ActionResult into a text representation for the agent. - Documents whose content exceeds the inline limit are persisted as - workspace files so the agent can access them via readFile / - ai_process / searchInFileContent. + Documents whose content exceeds the inline limit (or is binary bytes) + are persisted as workspace files. + + Returns (str, list) – the formatted text and a list of sideEvent dicts. """ parts = [] + sideEvents = [] ctx = context or {} if result.resultLabel: @@ -296,13 +349,23 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] docType = getattr(doc, "mimeType", "unknown") docData = getattr(doc, "documentData", None) - isLarge = docData and isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT - if isLarge: - persistedLine = _persistLargeDocument(doc, services, ctx) - if persistedLine: + needsPersist = ( + (isinstance(docData, bytes) and len(docData) > 0) or + (isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT) + ) + if needsPersist: + persisted = _persistLargeDocument(doc, services, ctx) + if persisted: parts.append(f" - {docName} ({docType})") - parts.append(f" {persistedLine}") + parts.append(f" {persisted['line']}") + sideEvents.append({ + "type": "fileCreated", + "data": persisted["fileInfo"], + }) continue + logger.error(f"Document '{docName}' ({docType}, {len(docData)} bytes) could not be persisted") + parts.append(f" - {docName} ({docType}) [ERROR: persistence failed]") + continue parts.append(f" - {docName} ({docType})") if docData and isinstance(docData, str) and len(docData) < _INLINE_CONTENT_LIMIT: @@ -311,4 +374,4 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] if not parts: parts.append("Action completed successfully." if result.success else "Action failed.") - return "\n".join(parts) + return "\n".join(parts), sideEvents diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index c1191c1f..fff1bcb3 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -228,14 +228,17 @@ def _registerDataSourceTools(registry: ToolRegistry, services): fileName = f"{fileName}.zip" chatService = services.chat fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName) - fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") - if fiId: - chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId}) - if _sourceNeutralize: - chatService.interfaceDbComponent.updateFile(fileItem.id, {"neutralize": True}) + updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) if tempFolderId: - chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId}) + updateFields["folderId"] = tempFolderId + fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + if fiId: + updateFields["featureInstanceId"] = fiId + if _sourceNeutralize: + updateFields["neutralize"] = True + if updateFields: + chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py index 37116ee5..cb815734 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py @@ -47,13 +47,29 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool: def _getOrCreateTempFolder(chatService) -> Optional[str]: - """Deprecated stub: folder-based organisation has been replaced by grouping. - - Returns None unconditionally so callers skip the (now removed) folderId - assignment. Remove callers incrementally and delete this stub afterwards. - """ - logger.debug("_getOrCreateTempFolder called – folder support removed, returning None") - return None + """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() + 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") + folderId = newFolder.get("id") if isinstance(newFolder, dict) else getattr(newFolder, "id", None) + logger.info("_getOrCreateTempFolder: created Temp folder %s for user %s", folderId, userId) + return str(folderId) if folderId else None + except Exception as e: + logger.warning("_getOrCreateTempFolder failed: %s", e) + return None async def _getOrCreateInstanceGroup( diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py index adb79ecf..a3fbb3ed 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py @@ -222,12 +222,15 @@ def _registerMediaTools(registry: ToolRegistry, services): if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") - fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") - if fiId: - chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) + updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) if tempFolderId: - chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) + updateFields["folderId"] = tempFolderId + fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + if fiId: + updateFields["featureInstanceId"] = fiId + if updateFields: + chatService.interfaceDbComponent.updateFile(fid, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, label=f"renderDocument:{docName}", @@ -517,12 +520,15 @@ def _registerMediaTools(registry: ToolRegistry, services): if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") - fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") - if fiId: - chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) + updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) if tempFolderId: - chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) + updateFields["folderId"] = tempFolderId + fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + if fiId: + updateFields["featureInstanceId"] = fiId + if updateFields: + chatService.interfaceDbComponent.updateFile(fid, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, label=f"generateImage:{docName}", @@ -679,12 +685,16 @@ def _registerMediaTools(registry: ToolRegistry, services): fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName) fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?" - fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") - if fiId and fid != "?": - chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId}) - tempFolderId = _getOrCreateTempFolder(chatService) - if tempFolderId and fid != "?": - chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId}) + if fid != "?": + updateFields = {} + tempFolderId = _getOrCreateTempFolder(chatService) + if tempFolderId: + updateFields["folderId"] = tempFolderId + fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") + if fiId: + updateFields["featureInstanceId"] = fiId + if updateFields: + chatService.interfaceDbComponent.updateFile(fid, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 17eb83e4..83f9de41 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -203,7 +203,8 @@ class AgentService: # ContentParts" symptom we see when the workspace route calls # runAgent for an attached single-file data source. # Mirrors workflowManager._propagateWorkflowToContext. - if workflowId and workflowId != "unknown": + isChatWorkflowId = workflowId and workflowId != "unknown" and ":" not in workflowId + if isChatWorkflowId: try: workflow = getattr(self.services, "workflow", None) if workflow is None or getattr(workflow, "id", None) != workflowId: diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index adfc4d8a..528cfd0d 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -433,6 +433,13 @@ class AiCallLooper: try: extracted = extractJsonString(contexts.completePart) parsed, parseErr, _ = tryParseJson(extracted) + if parseErr is not None: + from modules.shared.jsonUtils import repairBrokenJson + repaired = repairBrokenJson(extracted) + if repaired: + parsed = repaired + parseErr = None + logger.info(f"Iteration {iteration}: repairBrokenJson succeeded for completePart") if parseErr is None and parsed: normalized = self._normalizeJsonStructure(parsed, useCase) result = json.dumps(normalized, indent=2, ensure_ascii=False) diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py index b31bc32d..1ebb7d0c 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py +++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py @@ -933,19 +933,10 @@ class StructureFiller: operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE if operationType == OperationTypeEnum.IMAGE_GENERATE: - maxPromptLength = 4000 - if len(generationPrompt) > maxPromptLength: - logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters") - generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0] - - # Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping) - self.services.utils.writeDebugFile( - generationPrompt, - f"{chapterId}_section_{sectionId}_prompt" - ) - + imagePrompt = self._buildImagePrompt(section, generationHint, language) + self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt") request = AiCallRequest( - prompt=generationPrompt, + prompt=imagePrompt, contentParts=[], options=AiCallOptions( operationType=operationType, @@ -956,8 +947,6 @@ class StructureFiller: checkWorkflowStopped(self.services) aiResponse = await self.aiService.callAi(request) generatedElements = [] - - # Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping) self.services.utils.writeDebugFile( aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), f"{chapterId}_section_{sectionId}_response" @@ -1036,7 +1025,7 @@ class StructureFiller: aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: - logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") + logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}") aiResponse = _AiResponseFallback(aiResponseJson) generatedElements = [] @@ -1115,19 +1104,10 @@ class StructureFiller: operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE if operationType == OperationTypeEnum.IMAGE_GENERATE: - maxPromptLength = 4000 - if len(generationPrompt) > maxPromptLength: - logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters") - generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0] - - # Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping) - self.services.utils.writeDebugFile( - generationPrompt, - f"{chapterId}_section_{sectionId}_prompt" - ) - + imagePrompt = self._buildImagePrompt(section, generationHint, language) + self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt") request = AiCallRequest( - prompt=generationPrompt, + prompt=imagePrompt, contentParts=[], options=AiCallOptions( operationType=operationType, @@ -1137,8 +1117,6 @@ class StructureFiller: ) aiResponse = await self.aiService.callAi(request) generatedElements = [] - - # Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping) self.services.utils.writeDebugFile( aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), f"{chapterId}_section_{sectionId}_response" @@ -1197,7 +1175,7 @@ class StructureFiller: aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: - logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") + logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}") aiResponse = _AiResponseFallback(aiResponseJson) generatedElements = [] @@ -1374,19 +1352,10 @@ class StructureFiller: operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE if operationType == OperationTypeEnum.IMAGE_GENERATE: - maxPromptLength = 4000 - if len(generationPrompt) > maxPromptLength: - logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters") - generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0] - - # Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping) - self.services.utils.writeDebugFile( - generationPrompt, - f"{chapterId}_section_{sectionId}_prompt" - ) - + imagePrompt = self._buildImagePrompt(section, generationHint, language) + self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt") request = AiCallRequest( - prompt=generationPrompt, + prompt=imagePrompt, contentParts=[], options=AiCallOptions( operationType=operationType, @@ -1396,8 +1365,6 @@ class StructureFiller: ) aiResponse = await self.aiService.callAi(request) generatedElements = [] - - # Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping) self.services.utils.writeDebugFile( aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse), f"{chapterId}_section_{sectionId}_response" @@ -1457,7 +1424,7 @@ class StructureFiller: aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: - logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") + logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}") aiResponse = _AiResponseFallback(aiResponseJson) generatedElements = [] @@ -2166,6 +2133,14 @@ Return only valid JSON. Do not include any explanatory text outside the JSON. """ return prompt + def _buildImagePrompt(self, section: Dict[str, Any], generationHint: str, language: str = "de") -> str: + """Build a concise image-generation prompt from generationHint only. + Image models need short, visual descriptions - not the full document + context or user prompt that can be hundreds of KB.""" + sectionTitle = section.get("title", "") + description = generationHint or sectionTitle or "Generate an image" + return f"{description}\nLanguage for any text in the image: {language.upper()}" + def _getContentStructureExample(self, contentType: str) -> str: """Get the JSON structure example for a specific content type.""" structures = { diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 0e69344a..8b2ea564 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -23,7 +23,7 @@ class ChatService: 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) + self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id, featureInstanceId=context.feature_instance_id) self.interfaceDbChat = getChatInterface( context.user, mandateId=context.mandate_id, diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py index 583c423c..d7c237fa 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py @@ -122,26 +122,37 @@ class BaseRenderer(ABC): "title": { "font_size": h1["sizePt"], "color": h1["color"], "bold": h1.get("weight") == "bold", "align": "left", + "space_before": 0, + "space_after": h1.get("spaceAfterPt", 8), }, "heading1": { "font_size": h1["sizePt"], "color": h1["color"], "bold": h1.get("weight") == "bold", "align": "left", + "space_before": h1.get("spaceBeforePt", 24), + "space_after": h1.get("spaceAfterPt", 8), }, "heading2": { "font_size": h2["sizePt"], "color": h2["color"], "bold": h2.get("weight") == "bold", "align": "left", + "space_before": h2.get("spaceBeforePt", 20), + "space_after": h2.get("spaceAfterPt", 6), }, "heading3": { "font_size": h3["sizePt"], "color": h3["color"], "bold": h3.get("weight") == "bold", "align": "left", + "space_before": h3.get("spaceBeforePt", 16), + "space_after": h3.get("spaceAfterPt", 4), }, "heading4": { "font_size": h4["sizePt"], "color": h4["color"], "bold": h4.get("weight") == "bold", "align": "left", + "space_before": h4.get("spaceBeforePt", 12), + "space_after": h4.get("spaceAfterPt", 3), }, "paragraph": { "font_size": para["sizePt"], "color": para["color"], "bold": False, "align": "left", + "line_height": para.get("lineSpacing", 1.15), }, "table_header": { "background": tbl["headerBg"], "text_color": tbl["headerFg"], @@ -157,6 +168,7 @@ class BaseRenderer(ABC): "bullet_list": { "font_size": lst["sizePt"], "color": para["color"], "indent": lst["indentPt"], + "bullet_char": lst.get("bulletChar", "\u2022"), }, "code_block": { "font": style["fonts"]["monospace"], diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index 7913a246..8ba20c6a 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -851,25 +851,35 @@ 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 using AI-generated styles.""" + """Render a JSON bullet list to PDF elements.""" try: content = list_data.get("content", {}) if not isinstance(content, dict): return [] items = content.get("items", []) bulletStyleDef = styles.get("bullet_list", {}) - normalStyle = self._createNormalStyle(styles) + indent = bulletStyleDef.get("indent", 18) + bulletStyle = ParagraphStyle( + "BulletItem", + fontSize=bulletStyleDef.get("font_size", 11), + textColor=self._hexToColor(bulletStyleDef.get("color", "#333333")), + leftIndent=indent, + firstLineIndent=-indent, + spaceAfter=2, + leading=bulletStyleDef.get("font_size", 11) * 1.25, + ) + bulletChar = bulletStyleDef.get("bullet_char", "\u2022") elements = [] for item in items: runs = self._inlineRunsForListItem(item) if isinstance(item, list): xml = self._renderInlineRunsToPdfXml(runs) - elements.append(Paragraph(f"\u2022 {_wrapEmojiSpansInXml(xml)}", normalStyle)) + elements.append(Paragraph(f"{bulletChar} {_wrapEmojiSpansInXml(xml)}", bulletStyle)) elif isinstance(item, str): - elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item)}", normalStyle)) + elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item)}", bulletStyle)) elif isinstance(item, dict) and "text" in item: - elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item['text'])}", normalStyle)) + elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item['text'])}", bulletStyle)) if elements: elements.append(Spacer(1, bulletStyleDef.get("space_after", 3))) diff --git a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py index b5a92641..1984f18d 100644 --- a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py +++ b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py @@ -17,10 +17,10 @@ DEFAULT_STYLE: Dict[str, Any] = { "background": "#FFFFFF", }, "headings": { - "h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 12, "spaceAfterPt": 6}, - "h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 10, "spaceAfterPt": 4}, - "h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 8, "spaceAfterPt": 3}, - "h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 6, "spaceAfterPt": 2}, + "h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 24, "spaceAfterPt": 8}, + "h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6}, + "h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4}, + "h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3}, }, "paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"}, "table": { diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index 163ed3b2..0aa0fc42 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -307,16 +307,35 @@ class ActionNodeExecutor: resolvedParams.pop("subject", None) resolvedParams.pop("body", None) - # 8. Execute action + # 8. Create progress parent so nested actions have a hierarchy + import time as _time + 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 + resolvedParams["parentOperationId"] = nodeOperationId + + # 9. Execute action logger.info("ActionNodeExecutor node %s calling %s.%s with %d params", nodeId, methodName, actionName, len(resolvedParams)) + actionSuccess = False try: executor = ActionExecutor(self.services) result = await executor.executeAction(methodName, actionName, resolvedParams) + actionSuccess = True except (_SubscriptionInactiveException, _BillingContextError): raise except Exception as e: logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e) return _normalizeError(e, outputSchema) + finally: + if chatService: + try: + chatService.progressLogFinish(nodeOperationId, actionSuccess) + except Exception: + pass # 9. Persist generated documents as files and build JSON-safe output docsList = [] diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 2af480e7..6faa12d5 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -93,7 +93,11 @@ def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPar fileId = ref.documentId fileMeta = mgmt.getFile(fileId) if not fileMeta: - logger.warning(f"_resolve_file_refs_to_content_parts: file {fileId} not found") + 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", "?")) continue fileData = mgmt.getFileData(fileId) if not fileData: From e9c39f8e316fbe4b60acbd004ad320b7b7a12077 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Thu, 7 May 2026 11:01:37 +0200 Subject: [PATCH 09/14] fixes redmine --- modules/features/redmine/datamodelRedmine.py | 2 + modules/features/redmine/serviceRedmine.py | 1 + .../features/redmine/serviceRedmineSync.py | 1 + .../services/serviceAgent/sandboxExecutor.py | 27 ++++++- .../methodRedmine/actions/listRelations.py | 73 +++++++++++++++++++ .../methodRedmine/actions/listTickets.py | 18 +++-- .../methods/methodRedmine/methodRedmine.py | 35 +++++++++ 7 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 modules/workflows/methods/methodRedmine/actions/listRelations.py diff --git a/modules/features/redmine/datamodelRedmine.py b/modules/features/redmine/datamodelRedmine.py index 61555826..e33ee407 100644 --- a/modules/features/redmine/datamodelRedmine.py +++ b/modules/features/redmine/datamodelRedmine.py @@ -223,6 +223,7 @@ class RedmineTicketMirror(PowerOnModel): fixedVersionName: Optional[str] = Field(default=None, json_schema_extra={"label": "Zielversion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) categoryId: Optional[int] = Field(default=None, json_schema_extra={"label": "Kategorie-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False}) categoryName: Optional[str] = Field(default=None, json_schema_extra={"label": "Kategorie", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + doneRatio: Optional[int] = Field(default=None, description="Redmine % done (0-100)", json_schema_extra={"label": "% erledigt", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False}) closedOnTs: Optional[float] = Field( default=None, description="Best-effort UTC epoch when the ticket transitioned to a closed status. Approximated as updatedOnTs for closed tickets at sync time; used by Stats to render the open-vs-total snapshot chart.", @@ -338,6 +339,7 @@ class RedmineTicketDto(BaseModel): fixedVersionName: Optional[str] = None categoryId: Optional[int] = None categoryName: Optional[str] = None + doneRatio: Optional[int] = None createdOn: Optional[str] = None updatedOn: Optional[str] = None customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list) diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py index f0cfbfb4..2aea0918 100644 --- a/modules/features/redmine/serviceRedmine.py +++ b/modules/features/redmine/serviceRedmine.py @@ -222,6 +222,7 @@ def _mirroredRowToDto( fixedVersionName=row.get("fixedVersionName"), categoryId=row.get("categoryId"), categoryName=row.get("categoryName"), + doneRatio=row.get("doneRatio"), createdOn=row.get("createdOn"), updatedOn=row.get("updatedOn"), customFields=[ diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py index 2fd269d1..32cd5a09 100644 --- a/modules/features/redmine/serviceRedmineSync.py +++ b/modules/features/redmine/serviceRedmineSync.py @@ -402,6 +402,7 @@ def _ticketRecordFromIssue( "fixedVersionName": fixed_version.get("name"), "categoryId": category.get("id"), "categoryName": category.get("name"), + "doneRatio": issue.get("done_ratio"), "createdOn": created_on, "updatedOn": updated_on, "createdOnTs": _parseRedmineDateToEpoch(created_on), diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py index e4671a70..c2e16506 100644 --- a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py +++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py @@ -17,7 +17,7 @@ SANDBOX_ALLOWED_MODULES = { } _PYTHON_BLOCKED_BUILTINS = { - "open", "exec", "eval", "compile", "__import__", "globals", "locals", + "exec", "eval", "compile", "__import__", "globals", "locals", "getattr", "setattr", "delattr", "breakpoint", "exit", "quit", "input", "memoryview", } @@ -73,6 +73,29 @@ def _buildRestrictedGlobals() -> Dict[str, Any]: return {"__builtins__": safeBuiltins} +class _VirtualFS: + """In-memory virtual filesystem for sandbox open() calls.""" + + def __init__(self): + self.files: Dict[str, str] = {} + + def open(self, name, mode="r", **_kwargs): + if "r" in mode and "+" not in mode: + if name not in self.files: + raise FileNotFoundError(f"Virtual file '{name}' not found") + buf = io.StringIO(self.files[name]) + buf.name = name + return buf + buf = io.StringIO() + buf.name = name + realClose = buf.close + def _flushingClose(): + self.files[name] = buf.getvalue() + realClose() + buf.close = _flushingClose + return buf + + def _makeReadFile(services): """Create a readFile(fileId) closure bound to the current services context.""" def readFile(fileId: str) -> str: @@ -92,6 +115,8 @@ async def executePython(code: str, *, services=None) -> Dict[str, Any]: def _run(): restrictedGlobals = _buildRestrictedGlobals() + vfs = _VirtualFS() + restrictedGlobals["__builtins__"]["open"] = vfs.open if services: restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services) capturedOutput = io.StringIO() diff --git a/modules/workflows/methods/methodRedmine/actions/listRelations.py b/modules/workflows/methods/methodRedmine/actions/listRelations.py new file mode 100644 index 00000000..90f44594 --- /dev/null +++ b/modules/workflows/methods/methodRedmine/actions/listRelations.py @@ -0,0 +1,73 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +"""Workflow action: list Redmine relations from the mirror.""" + +import logging +from typing import Any, Dict, List, Optional + +from modules.datamodels.datamodelChat import ActionResult +from modules.features.redmine.interfaceFeatureRedmine import getInterface + +from ._shared import resolveInstanceContext + +logger = logging.getLogger(__name__) + + +async def listRelationsAction(self, parameters: Dict[str, Any]) -> ActionResult: + """List all mirrored relations, optionally filtered by issueId or relationType.""" + try: + user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters) + except ValueError as exc: + return ActionResult.isFailure(error=str(exc)) + + iface = getInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId) + rows = iface.listMirroredRelations(featureInstanceId) + + issueId: Optional[int] = None + rawIssueId = parameters.get("issueId") + if rawIssueId not in (None, ""): + try: + issueId = int(rawIssueId) + except (TypeError, ValueError): + return ActionResult.isFailure(error="issueId must be an int") + + relationType = parameters.get("relationType") or None + + if issueId is not None: + rows = [ + r for r in rows + if int(r.get("issueId") or 0) == issueId + or int(r.get("issueToId") or 0) == issueId + ] + if relationType: + rows = [r for r in rows if r.get("relationType") == relationType] + + limit = 1000 + try: + limit = max(1, min(5000, int(parameters.get("limit") or 1000))) + except (TypeError, ValueError): + limit = 1000 + + offset = 0 + try: + offset = max(0, int(parameters.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + + page = rows[offset:offset + limit] + return ActionResult.isSuccess(data={ + "count": len(page), + "totalMatched": len(rows), + "offset": offset, + "hasMore": (offset + limit) < len(rows), + "relations": [ + { + "redmineRelationId": r.get("redmineRelationId"), + "issueId": r.get("issueId"), + "issueToId": r.get("issueToId"), + "relationType": r.get("relationType"), + "delay": r.get("delay"), + } + for r in page + ], + }) diff --git a/modules/workflows/methods/methodRedmine/actions/listTickets.py b/modules/workflows/methods/methodRedmine/actions/listTickets.py index d1867b86..8573237a 100644 --- a/modules/workflows/methods/methodRedmine/actions/listTickets.py +++ b/modules/workflows/methods/methodRedmine/actions/listTickets.py @@ -64,19 +64,23 @@ async def listTicketsAction(self, parameters: Dict[str, Any]) -> ActionResult: logger.exception("redmine.listTickets failed") return ActionResult.isFailure(error=f"List tickets failed: {exc}") - # AI-friendly pagination: always capped so we don't accidentally feed a - # 20k-ticket dump into a context window. Callers that need more must - # paginate via filters. limit = 100 try: limit = max(1, min(500, int(parameters.get("limit") or 100))) except (TypeError, ValueError): limit = 100 - truncated = tickets[:limit] + offset = 0 + try: + offset = max(0, int(parameters.get("offset") or 0)) + except (TypeError, ValueError): + offset = 0 + + page = tickets[offset:offset + limit] return ActionResult.isSuccess(data={ - "count": len(truncated), + "count": len(page), "totalMatched": len(tickets), - "truncated": len(tickets) > limit, - "tickets": [ticketToDict(t) for t in truncated], + "offset": offset, + "hasMore": (offset + limit) < len(tickets), + "tickets": [ticketToDict(t) for t in page], }) diff --git a/modules/workflows/methods/methodRedmine/methodRedmine.py b/modules/workflows/methods/methodRedmine/methodRedmine.py index 6c40c951..700375cd 100644 --- a/modules/workflows/methods/methodRedmine/methodRedmine.py +++ b/modules/workflows/methods/methodRedmine/methodRedmine.py @@ -22,6 +22,7 @@ from modules.workflows.methods.methodBase import MethodBase from .actions.createTicket import createTicketAction from .actions.getStats import getStatsAction +from .actions.listRelations import listRelationsAction from .actions.listTickets import listTicketsAction from .actions.readTicket import readTicket from .actions.runSync import runSyncAction @@ -90,9 +91,42 @@ class MethodRedmine(MethodBase): name="limit", type="int", frontendType=FrontendType.TEXT, required=False, description="Max tickets in the result (1-500, default 100).", ), + "offset": WorkflowActionParameter( + name="offset", type="int", frontendType=FrontendType.TEXT, + required=False, description="Skip first N matched tickets (for pagination, default 0).", + ), }, execute=listTicketsAction.__get__(self, self.__class__), ), + "listRelations": WorkflowActionDefinition( + actionId="redmine.listRelations", + description="List all mirrored relations. Optional filters: issueId, relationType. Returns issueId<->issueToId pairs with relationType.", + dynamicMode=False, + outputType="RedmineRelationList", + parameters={ + "featureInstanceId": WorkflowActionParameter( + name="featureInstanceId", type="FeatureInstanceRef", frontendType=FrontendType.TEXT, + required=True, description="Redmine feature instance", + ), + "issueId": WorkflowActionParameter( + name="issueId", type="int", frontendType=FrontendType.TEXT, + required=False, description="Filter relations involving this Redmine issue id (as source or target).", + ), + "relationType": WorkflowActionParameter( + name="relationType", type="str", frontendType=FrontendType.TEXT, + required=False, description="Filter by relation type: relates | precedes | follows | blocks | blocked | duplicates | duplicated | copied_to | copied_from.", + ), + "limit": WorkflowActionParameter( + name="limit", type="int", frontendType=FrontendType.TEXT, + required=False, description="Max relations in the result (1-5000, default 1000).", + ), + "offset": WorkflowActionParameter( + name="offset", type="int", frontendType=FrontendType.TEXT, + required=False, description="Skip first N relations (for pagination, default 0).", + ), + }, + execute=listRelationsAction.__get__(self, self.__class__), + ), "createTicket": WorkflowActionDefinition( actionId="redmine.createTicket", description="Create a new Redmine ticket. Requires subject and trackerId.", @@ -253,6 +287,7 @@ class MethodRedmine(MethodBase): # rather than through the action dict also work. self.readTicket = readTicket.__get__(self, self.__class__) self.listTickets = listTicketsAction.__get__(self, self.__class__) + self.listRelations = listRelationsAction.__get__(self, self.__class__) self.createTicket = createTicketAction.__get__(self, self.__class__) self.updateTicket = updateTicketAction.__get__(self, self.__class__) self.getStats = getStatsAction.__get__(self, self.__class__) From 73fe11230d4fde94680441e5b453fcf993ba1913 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 8 May 2026 11:49:03 +0200 Subject: [PATCH 10/14] clean --- env_dev.20260428_213450.backup | 107 ------------------------ env_int.20260428_213451.backup | 100 ---------------------- env_prod.20260428_213451.backup | 101 ---------------------- env_prod_forgejo.20260428_213451.backup | 101 ---------------------- 4 files changed, 409 deletions(-) delete mode 100644 env_dev.20260428_213450.backup delete mode 100644 env_int.20260428_213451.backup delete mode 100644 env_prod.20260428_213451.backup delete mode 100644 env_prod_forgejo.20260428_213451.backup diff --git a/env_dev.20260428_213450.backup b/env_dev.20260428_213450.backup deleted file mode 100644 index b6cffdf0..00000000 --- a/env_dev.20260428_213450.backup +++ /dev/null @@ -1,107 +0,0 @@ -# Development Environment Configuration - -# System Configuration -APP_ENV_TYPE = dev -APP_ENV_LABEL = Development Instance Patrick -APP_API_URL = http://localhost:8000 -APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt -APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9 -APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 - -# PostgreSQL DB Host -DB_HOST=localhost -DB_USER=poweron_dev -DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9 -DB_PORT=5432 - -# Security Configuration -APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ== -APP_TOKEN_EXPIRY=300 - -# CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net - -# Logging configuration -APP_LOGGING_LOG_LEVEL = DEBUG -APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/local/logs -APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s -APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S -APP_LOGGING_CONSOLE_ENABLED = True -APP_LOGGING_FILE_ENABLED = True -APP_LOGGING_ROTATION_SIZE = 10485760 -APP_LOGGING_BACKUP_COUNT = 5 - -# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP. -Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk= -Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback -Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk= -Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback - -Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM= -Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback -Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM= -Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback - -# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. -Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 -Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ== -Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback - -# Infomaniak OAuth -- Data App (kDrive + Mail) -Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b -Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz -Service_INFOMANIAK_OAUTH_REDIRECT_URI = http://localhost:8000/api/infomaniak/auth/connect/callback - -# Stripe Billing (both end with _SECRET for encryption script) -STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09 -STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09 -STRIPE_API_VERSION = 2026-01-28.clover -STRIPE_AUTOMATIC_TAX_ENABLED = false -STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0 - -# AI configuration -Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9 -Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09 -Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5ZmdDZ3hrSElrMnQzNFAtel9wX191VjVzN2g1LWZoa0V1YklubEdmMEJDdEZiR1RWeVZrM3V3enBHX3p6WUtTS0kwYkFyVEF0Nm8zX05CelVQcFJUc0lwVW5iNFczc1p1WWJ2WFBmd0lpLUxxWndEeUh0b2hGUHVpN19vb19nMTBnV1A1VmNpWERVX05lQ29VS20wTjZ3PT0= -Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI= -Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY= -Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE= - -Service_MSFT_TENANT_ID = common - -# Google Cloud Speech Services configuration -Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0= - -# Feature SyncDelta JIRA configuration -Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0= - -# Teamsbot Browser Bot Service -# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot -# The bot will connect back to localhost:8000 via WebSocket -TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100 - -# Debug Configuration -APP_DEBUG_CHAT_WORKFLOW_ENABLED = True -APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug -APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True -APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync - -# Manadate Pre-Processing Servers -PREPROCESS_ALTHAUS_CHAT_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGbEphQ3ZUMlFMQ2EwSGpoSE9NNzRJNTJtaGk1N0RGakdIYnVVeVFHZmF5OXB3QTVWLVNaZk9wNkhfQkZWRnVwRGRxem9iRzJIWXdpX1NIN2FwSExfT3c9PQ== - -# Preprocessor API Configuration -PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990 -PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query - -# Azure Communication Services Email Configuration -MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt -MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss - -# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves. -# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av -# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a - diff --git a/env_int.20260428_213451.backup b/env_int.20260428_213451.backup deleted file mode 100644 index 45236a09..00000000 --- a/env_int.20260428_213451.backup +++ /dev/null @@ -1,100 +0,0 @@ -# Integration Environment Configuration - -# System Configuration -APP_ENV_TYPE = int -APP_ENV_LABEL = Integration Instance -APP_API_URL = https://gateway-int.poweron-center.net -APP_KEY_SYSVAR = CONFIG_KEY -APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9 -APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9 - -# PostgreSQL DB Host -DB_HOST=gateway-int-server.postgres.database.azure.com -DB_USER=heeshkdlby -DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9 -DB_PORT=5432 - -# Security Configuration -APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ== -APP_TOKEN_EXPIRY=300 - -# CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net, https://nyla-int.poweron-center.net - -# Logging configuration -APP_LOGGING_LOG_LEVEL = DEBUG -APP_LOGGING_LOG_DIR = /home/site/wwwroot/ -APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s -APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S -APP_LOGGING_CONSOLE_ENABLED = True -APP_LOGGING_FILE_ENABLED = True -APP_LOGGING_ROTATION_SIZE = 10485760 -APP_LOGGING_BACKUP_COUNT = 5 - -# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs) -Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk= -Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback -Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk= -Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback - -Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo= -Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback -Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo= -Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback - -# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. -Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 -Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ== -Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/clickup/auth/connect/callback - -# Infomaniak OAuth -- Data App (kDrive + Mail) -Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b -Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz -Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/infomaniak/auth/connect/callback - -# Stripe Billing (both end with _SECRET for encryption script) -STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09 -STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M -STRIPE_API_VERSION = 2026-01-28.clover -STRIPE_AUTOMATIC_TAX_ENABLED = false -STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0 - -# AI configuration -Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9 -Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09 -Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnB5dkd6UkhtU3lhYmZMSlo0bklQZ2s3UTFBSkprZTNwWkg5Q2lVa0wtenhxWXpva21xVDVMRjdKSmhpTmxWS05IUTRoRHdCbktSRVVjcVFnY1RfV0N2S2dyV0dTMlhxQlRFVm41RkFTWVQzQThuVkZwdlNuVC05QlVRVXB6Qjk3akNpYmY1MFR6R1ByMzlIMllRZlRRYVVRN2ZBPT0= -Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk= -Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg= -Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI= - -Service_MSFT_TENANT_ID = common - -# Google Cloud Speech Services configuration -Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0= - -# Feature SyncDelta JIRA configuration -Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0= - -# Teamsbot Browser Bot Service -TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io - -# Debug Configuration -APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE -APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat -APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE -APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync - -# Manadate Pre-Processing Servers -PREPROCESS_ALTHAUS_CHAT_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4UkNBelhvckxCQUVjZm94N3BZUDcxaEMyckE2dm1lRVhqODhrWU1SUjNXZ3dQZlVJOWhveXFkZXpobW5xT0NneGZ2SkNUblFmYXd0WTBYNTl3UmRnSWc9PQ== - -# Preprocessor API Configuration -PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990 -PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query - -# Azure Communication Services Email Configuration -MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt -MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss diff --git a/env_prod.20260428_213451.backup b/env_prod.20260428_213451.backup deleted file mode 100644 index d7307743..00000000 --- a/env_prod.20260428_213451.backup +++ /dev/null @@ -1,101 +0,0 @@ -# Production Environment Configuration - -# System Configuration -APP_ENV_TYPE = prod -APP_ENV_LABEL = Production Instance -APP_KEY_SYSVAR = CONFIG_KEY -APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 -APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 -APP_API_URL = https://gateway-prod.poweron-center.net - -# PostgreSQL DB Host -DB_HOST=gateway-prod-server.postgres.database.azure.com -DB_USER=gzxxmcrdhn -DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9 -DB_PORT=5432 - -# Security Configuration -APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ== -APP_TOKEN_EXPIRY=300 - -# CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net - -# Logging configuration -APP_LOGGING_LOG_LEVEL = DEBUG -APP_LOGGING_LOG_DIR = /home/site/wwwroot/ -APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s -APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S -APP_LOGGING_CONSOLE_ENABLED = True -APP_LOGGING_FILE_ENABLED = True -APP_LOGGING_ROTATION_SIZE = 10485760 -APP_LOGGING_BACKUP_COUNT = 5 - -# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs) -Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= -Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback -Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= -Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback - -Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= -Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback -Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= -Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback - -# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. -Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 -Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ== -Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback - -# Infomaniak OAuth -- Data App (kDrive + Mail) -Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b -Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz -Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/infomaniak/auth/connect/callback - -# Stripe Billing (both end with _SECRET for encryption script) -STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09 -STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08= -STRIPE_API_VERSION = 2026-01-28.clover -STRIPE_AUTOMATIC_TAX_ENABLED = false -STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah - - -# AI configuration -Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9 -Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09 -Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0= -Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg= -Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo= -Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA= - -Service_MSFT_TENANT_ID = common - -# Google Cloud Speech Services configuration -Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0= - -# Feature SyncDelta JIRA configuration -Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0= - -# Teamsbot Browser Bot Service -TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io - -# Debug Configuration -APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE -APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat -APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE -APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync - -# Manadate Pre-Processing Servers -PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo= - -# Preprocessor API Configuration -PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990 -PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query - -# Azure Communication Services Email Configuration -MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt -MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss diff --git a/env_prod_forgejo.20260428_213451.backup b/env_prod_forgejo.20260428_213451.backup deleted file mode 100644 index f6193f2c..00000000 --- a/env_prod_forgejo.20260428_213451.backup +++ /dev/null @@ -1,101 +0,0 @@ -# Production Environment Configuration - -# System Configuration -APP_ENV_TYPE = prod -APP_ENV_LABEL = Production Instance Forgejo -APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt -APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 -APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 -APP_API_URL = https://api.poweron.swiss - -# PostgreSQL DB Host -DB_HOST=10.20.0.21 -DB_USER=poweron_dev -DB_PASSWORD_SECRET = mypassword -DB_PORT=5432 - -# Security Configuration -APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ== -APP_TOKEN_EXPIRY=300 - -# CORS Configuration -APP_ALLOWED_ORIGINS=https://porta.poweron.swiss - -# Logging configuration -APP_LOGGING_LOG_LEVEL = DEBUG -APP_LOGGING_LOG_DIR = srv/gateway/shared/logs -APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s -APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S -APP_LOGGING_CONSOLE_ENABLED = True -APP_LOGGING_FILE_ENABLED = True -APP_LOGGING_ROTATION_SIZE = 10485760 -APP_LOGGING_BACKUP_COUNT = 5 - -# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs) -Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= -Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback -Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= -Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback - -Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= -Service_GOOGLE_AUTH_REDIRECT_URI = -Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= -Service_GOOGLE_DATA_REDIRECT_URI = - -# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. -Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 -Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ== -Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback - -# Infomaniak OAuth -- Data App (kDrive + Mail) -Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b -Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz -Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/infomaniak/auth/connect/callback - -# Stripe Billing (both end with _SECRET for encryption script) -STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09 -STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08= -STRIPE_API_VERSION = 2026-01-28.clover -STRIPE_AUTOMATIC_TAX_ENABLED = false -STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah - - -# AI configuration -Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9 -Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09 -Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0= -Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg= -Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo= -Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA= - -Service_MSFT_TENANT_ID = common - -# Google Cloud Speech Services configuration -Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0= - -# Feature SyncDelta JIRA configuration -Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0= - -# Teamsbot Browser Bot Service -TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io - -# Debug Configuration -APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE -APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat -APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE -APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync - -# Manadate Pre-Processing Servers -PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo= - -# Preprocessor API Configuration -PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990 -PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query - -# Azure Communication Services Email Configuration -MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt -MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss From 130bdfb7ccdadfb8d650f9d4c94d848e23bd6c5f Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 8 May 2026 13:11:18 +0200 Subject: [PATCH 11/14] siwtch dns poweron-center to poweron.swiss --- .dockerignore | 6 +++--- .forgejo/workflows/deploy.yml | 4 ++-- .gcloudignore | 2 +- .github/workflows/deploy-gcp.yml | 16 ++++++++-------- .github/workflows/int_gateway-int.yml | 4 ++-- .github/workflows/main_gateway-prod.yml | 4 ++-- .gitignore | 2 +- Dockerfile | 4 ++-- config.ini | 5 ----- env_dev.env => env-gateway-dev.env | 2 +- env_int.env => env-gateway-int.env | 14 +++++++------- ...d_forgejo.env => env-gateway-prod-forgejo.env | 0 env_prod.env => env-gateway-prod.env | 14 +++++++------- modules/features/teamsbot/datamodelTeamsbot.py | 2 +- .../services/serviceBilling/stripeCheckout.py | 6 +++--- .../script_db_cleanup_duplicate_roles.py | 4 ++-- scripts/script_db_audit_legacy_state.py | 2 +- scripts/script_security_encrypt_all_env_files.py | 4 ++-- scripts/script_security_encrypt_config_value.py | 4 ++-- 19 files changed, 47 insertions(+), 52 deletions(-) rename env_dev.env => env-gateway-dev.env (99%) rename env_int.env => env-gateway-int.env (94%) rename env_prod_forgejo.env => env-gateway-prod-forgejo.env (100%) rename env_prod.env => env-gateway-prod.env (94%) diff --git a/.dockerignore b/.dockerignore index bf5e88f5..541dbb07 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,10 +29,10 @@ ENV *.swo *~ -# Environment files (env_gcp.env will be copied as .env by workflow) -env_*.env +# Environment files (env-gateway-*.env will be copied as .env by workflow) +env-*.env .env.local -# Note: .env is NOT ignored - it will be created from env_gcp.env by the workflow +# Note: .env is NOT ignored - it will be created from env-gateway-*.env by the workflow # Logs *.log diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index b4d2a654..8c44cfa5 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -22,8 +22,8 @@ jobs: cd /srv/gateway/current && git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/gateway.git && git pull && - cp env_prod_forgejo.env .env && - rm -f env_*.env && + cp env-gateway-prod-forgejo.env .env && + rm -f env-*.env && source .venv/bin/activate && pip install -r requirements.txt --no-cache-dir && sudo systemctl restart gateway diff --git a/.gcloudignore b/.gcloudignore index ca683390..d7934f65 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -30,7 +30,7 @@ ENV *~ # Environment files (will be handled separately) -env_*.env +env-*.env .env.local # Logs diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml index d6a96276..306c0f60 100644 --- a/.github/workflows/deploy-gcp.yml +++ b/.github/workflows/deploy-gcp.yml @@ -11,11 +11,11 @@ # 2. Create secret "CONFIG_KEY" in Secret Manager with your master key # 3. Grant the service account access to Secret Manager secrets # 4. Create Cloud SQL instance (if not exists) -# 5. Create env_prod.env and env_int.env files with your configuration +# 5. Create env-gateway-prod.env and env-gateway-int.env files with your configuration # # Environment Selection: -# - Push to 'main' branch → uses env_prod.env (production) -# - Push to 'int' branch → uses env_int.env (integration) +# - Push to 'main' branch → uses env-gateway-prod.env (production) +# - Push to 'int' branch → uses env-gateway-int.env (integration) # - Manual dispatch → select environment (prod/int) to use corresponding env file name: Deploy Gateway to Google Cloud Run @@ -70,10 +70,10 @@ jobs: fi echo "env_type=$ENV_TYPE" >> $GITHUB_OUTPUT echo "service_name=gateway-$ENV_TYPE" >> $GITHUB_OUTPUT - echo "env_file=env_${ENV_TYPE}.env" >> $GITHUB_OUTPUT + echo "env_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT echo "Determined environment: $ENV_TYPE" echo "Service name: gateway-$ENV_TYPE" - echo "Env file: env_${ENV_TYPE}.env" + echo "Env file: env-gateway-${ENV_TYPE}.env" - name: Authenticate to Google Cloud uses: google-github-actions/auth@v2 @@ -98,11 +98,11 @@ jobs: echo "Using $ENV_FILE" cp "$ENV_FILE" .env else - echo "Warning: $ENV_FILE not found, using env_prod.env as fallback" - cp env_prod.env .env + echo "Warning: $ENV_FILE not found, using env-gateway-prod.env as fallback" + cp env-gateway-prod.env .env fi # Clean up other env files (optional, for security) - rm -f env_*.env + rm -f env-*.env - name: Build and push container image working-directory: ./gateway diff --git a/.github/workflows/int_gateway-int.yml b/.github/workflows/int_gateway-int.yml index 9e3a20da..80f93583 100644 --- a/.github/workflows/int_gateway-int.yml +++ b/.github/workflows/int_gateway-int.yml @@ -74,10 +74,10 @@ jobs: run: unzip release.zip - name: Set productive environment - run: cp env_int.env .env + run: cp env-gateway-int.env .env - name: Clean up environment files - run: rm -f env_*.env + run: rm -f env-*.env - name: 'Deploy to Azure Web App' uses: azure/webapps-deploy@v3 diff --git a/.github/workflows/main_gateway-prod.yml b/.github/workflows/main_gateway-prod.yml index 0d2edd70..b45db585 100644 --- a/.github/workflows/main_gateway-prod.yml +++ b/.github/workflows/main_gateway-prod.yml @@ -74,10 +74,10 @@ jobs: run: unzip release.zip - name: Set productive environment - run: cp env_prod.env .env + run: cp env-gateway-prod.env .env - name: Clean up environment files - run: rm -f env_*.env + run: rm -f env-*.env - name: 'Deploy to Azure Web App' uses: azure/webapps-deploy@v3 diff --git a/.gitignore b/.gitignore index df4b0c6c..cd314f70 100644 --- a/.gitignore +++ b/.gitignore @@ -131,7 +131,7 @@ env.bak/ venv.bak/ # Don't ignore environment templates -!env*.env +!env-*.env # Spyder project settings .spyderproject diff --git a/Dockerfile b/Dockerfile index fcf2d2f5..f476a47c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,13 +28,13 @@ COPY requirements.lock . RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -r requirements.lock -# Copy application code (includes .env file created by workflow from env_gcp.env) +# Copy application code (includes .env file created by workflow from env-gateway-*.env) COPY . . # Create directories for logs (Cloud Run uses /tmp for writable storage) RUN mkdir -p /tmp/logs /tmp/debug -# Note: .env file (created from env_gcp.env by workflow) contains encrypted secrets +# Note: .env file (created from env-gateway-*.env by workflow) contains encrypted secrets # These are decrypted at runtime using the master key from Secret Manager # (mounted as CONFIG_KEY environment variable in Cloud Run) diff --git a/config.ini b/config.ini index 90377f07..4a37f2f8 100644 --- a/config.ini +++ b/config.ini @@ -45,11 +45,6 @@ Connector_StacSwisstopo_MAX_RETRIES = 3 Connector_StacSwisstopo_RETRY_DELAY = 1.0 Connector_StacSwisstopo_ENABLE_CACHE = True -# Demo RMA credentials (same for all demo trustee instances) -Demo_RMA_ApiBaseUrl = https://service.int.runmyaccounts.com/api/latest/clients/ -Demo_RMA_ClientName = poweronag -Demo_RMA_ApiKey = pat_tipTbnHU26CrMzAnLSjCR_uzHJv4CDNa7obaQGHIA-4 - # Operator company information (shown on invoice emails) Operator_CompanyName = PowerOn AG Operator_Address = Birmensdorferstrasse 94, 8003 Zürich diff --git a/env_dev.env b/env-gateway-dev.env similarity index 99% rename from env_dev.env rename to env-gateway-dev.env index 5ae0d219..3709b0d8 100644 --- a/env_dev.env +++ b/env-gateway-dev.env @@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2Z APP_TOKEN_EXPIRY=300 # CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net +APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss # Logging configuration APP_LOGGING_LOG_LEVEL = DEBUG diff --git a/env_int.env b/env-gateway-int.env similarity index 94% rename from env_int.env rename to env-gateway-int.env index f6e8d8fa..10272b34 100644 --- a/env_int.env +++ b/env-gateway-int.env @@ -3,7 +3,7 @@ # System Configuration APP_ENV_TYPE = int APP_ENV_LABEL = Integration Instance -APP_API_URL = https://gateway-int.poweron-center.net +APP_API_URL = https://gateway-int.poweron.swiss APP_KEY_SYSVAR = CONFIG_KEY APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9 APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9 @@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ APP_TOKEN_EXPIRY=300 # CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net, https://nyla-int.poweron-center.net +APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss # Logging configuration APP_LOGGING_LOG_LEVEL = DEBUG @@ -34,22 +34,22 @@ APP_LOGGING_BACKUP_COUNT = 5 # OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs) Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk= -Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback +Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/login/callback Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk= -Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback +Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/connect/callback Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo= -Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback +Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo= -Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback +Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/connect/callback # ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ== -Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/clickup/auth/connect/callback +Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/clickup/auth/connect/callback # Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI. diff --git a/env_prod_forgejo.env b/env-gateway-prod-forgejo.env similarity index 100% rename from env_prod_forgejo.env rename to env-gateway-prod-forgejo.env diff --git a/env_prod.env b/env-gateway-prod.env similarity index 94% rename from env_prod.env rename to env-gateway-prod.env index 09ec8c34..67202109 100644 --- a/env_prod.env +++ b/env-gateway-prod.env @@ -6,7 +6,7 @@ APP_ENV_LABEL = Production Instance APP_KEY_SYSVAR = CONFIG_KEY APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 -APP_API_URL = https://gateway-prod.poweron-center.net +APP_API_URL = https://gateway-prod.poweron.swiss # PostgreSQL DB Host DB_HOST=gateway-prod-server.postgres.database.azure.com @@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl APP_TOKEN_EXPIRY=300 # CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net +APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss # Logging configuration APP_LOGGING_LOG_LEVEL = DEBUG @@ -34,22 +34,22 @@ APP_LOGGING_BACKUP_COUNT = 5 # OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs) Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= -Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback +Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/login/callback Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= -Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback +Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= -Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback +Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= -Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback +Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/connect/callback # ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ== -Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback +Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/clickup/auth/connect/callback # Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI. diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py index 8c8d61e7..a7a22c9b 100644 --- a/modules/features/teamsbot/datamodelTeamsbot.py +++ b/modules/features/teamsbot/datamodelTeamsbot.py @@ -392,7 +392,7 @@ class BridgeJoinRequest(BaseModel): gatewayCallbackUrl: str = Field(description="Gateway URL for bridge callbacks") gatewayWsUrl: str = Field(description="Gateway WebSocket URL for audio streaming") sessionId: str = Field(description="Session ID for correlation") - gatewayBaseUrl: str = Field(description="Base URL of this gateway instance (e.g. https://gateway-prod.poweron-center.net)") + gatewayBaseUrl: str = Field(description="Base URL of this gateway instance (e.g. https://gateway-prod.poweron.swiss)") class BridgeStatusResponse(BaseModel): diff --git a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py index 97921df8..bb9feea7 100644 --- a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py +++ b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py @@ -183,8 +183,8 @@ def _normalizeReturnUrl(returnUrl: str) -> str: Validate and normalize an absolute frontend return URL. Allowed examples: - - https://nyla.poweron-center.net/billing/transactions - - https://nyla-int.poweron-center.net/billing/transactions?tab=overview + - https://nyla.poweron.swiss/billing/transactions + - https://nyla-int.poweron.swiss/billing/transactions?tab=overview """ if not returnUrl: raise ValueError("returnUrl is required") @@ -309,7 +309,7 @@ def create_checkout_session( "footer": ( "Diese Rechnung wurde bereits via Kreditkarte bezahlt. " "MWST-Nr. PowerOn: siehe Stripe-Rechnungs-Template. " - "Bei Fragen: billing@poweron-center.net" + "Bei Fragen: billing@poweron.swiss" ), } customFields: List[Dict[str, str]] = [] diff --git a/scripts/_archive/script_db_cleanup_duplicate_roles.py b/scripts/_archive/script_db_cleanup_duplicate_roles.py index 392cde80..1ded5a51 100644 --- a/scripts/_archive/script_db_cleanup_duplicate_roles.py +++ b/scripts/_archive/script_db_cleanup_duplicate_roles.py @@ -22,9 +22,9 @@ import os gatewayDir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, gatewayDir) -# Load environment variables from env_dev.env +# Load environment variables from env-gateway-dev.env from dotenv import load_dotenv -envPath = os.path.join(gatewayDir, "env_dev.env") +envPath = os.path.join(gatewayDir, "env-gateway-dev.env") if os.path.exists(envPath): load_dotenv(envPath) diff --git a/scripts/script_db_audit_legacy_state.py b/scripts/script_db_audit_legacy_state.py index f51a132a..54ee6474 100644 --- a/scripts/script_db_audit_legacy_state.py +++ b/scripts/script_db_audit_legacy_state.py @@ -51,7 +51,7 @@ if _gatewayDir not in sys.path: from dotenv import load_dotenv -_envPath = os.path.join(_gatewayDir, "env_dev.env") +_envPath = os.path.join(_gatewayDir, "env-gateway-dev.env") if os.path.exists(_envPath): load_dotenv(_envPath) diff --git a/scripts/script_security_encrypt_all_env_files.py b/scripts/script_security_encrypt_all_env_files.py index 9981ad52..cceae83d 100644 --- a/scripts/script_security_encrypt_all_env_files.py +++ b/scripts/script_security_encrypt_all_env_files.py @@ -19,7 +19,7 @@ Usage: python script_security_encrypt_all_env_files.py --no-backup # Process only specific environment files - python script_security_encrypt_all_env_files.py --files env_dev.env env_prod.env + python script_security_encrypt_all_env_files.py --files env-gateway-dev.env env-gateway-prod.env """ import sys @@ -308,7 +308,7 @@ def process_all_env_files(env_files: List[str] = None, dry_run: bool = False, cr """ # Default environment files if none specified if env_files is None: - env_files = ['env_dev.env', 'env_int.env', 'env_prod.env'] + env_files = ['env-gateway-dev.env', 'env-gateway-int.env', 'env-gateway-prod.env'] # Convert to Path objects and check if they exist env_paths = [] diff --git a/scripts/script_security_encrypt_config_value.py b/scripts/script_security_encrypt_config_value.py index afaeb827..512d9958 100644 --- a/scripts/script_security_encrypt_config_value.py +++ b/scripts/script_security_encrypt_config_value.py @@ -14,8 +14,8 @@ Usage: python script_security_encrypt_config_value.py --file "path/to/file.json" --env prod # Encrypt all secrets in a file - python script_security_encrypt_config_value.py --encrypt-all env_dev.env --env dev - python script_security_encrypt_config_value.py --encrypt-all env_prod.env --env prod --dry-run + python script_security_encrypt_config_value.py --encrypt-all env-gateway-dev.env --env dev + python script_security_encrypt_config_value.py --encrypt-all env-gateway-prod.env --env prod --dry-run # Decrypt a value (for testing) python script_security_encrypt_config_value.py --decrypt "DEV_ENC:encrypted_value" From 35693a61e3f3e926f8615e90272ed9ba1df6a031 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 8 May 2026 13:58:12 +0200 Subject: [PATCH 12/14] Cross-Site cookies enabled --- modules/auth/jwtService.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/modules/auth/jwtService.py b/modules/auth/jwtService.py index 439d3282..422a4951 100644 --- a/modules/auth/jwtService.py +++ b/modules/auth/jwtService.py @@ -24,6 +24,11 @@ REFRESH_TOKEN_EXPIRE_DAYS = int(APP_CONFIG.get("APP_REFRESH_TOKEN_EXPIRY", "7")) APP_API_URL = APP_CONFIG.get("APP_API_URL", "http://localhost:8000") USE_SECURE_COOKIES = APP_API_URL.startswith("https://") if APP_API_URL else False +# Cross-origin SPA (any host) + API (gateway host): SameSite=None + Secure is required so the +# browser sends cookies on credentialed XHR/fetch. HTTP localhost uses Lax (None without Secure is rejected). +COOKIE_SAMESITE = "none" if USE_SECURE_COOKIES else "lax" +COOKIE_SAMESITE_SET_COOKIE = "None" if USE_SECURE_COOKIES else "Lax" + def createAccessToken(data: dict, expiresDelta: Optional[timedelta] = None) -> Tuple[str, "datetime"]: """Create a JWT access token and return (token, expiresAt).""" @@ -60,7 +65,7 @@ def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[ value=token, httponly=True, secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS) - samesite="strict", + samesite=COOKIE_SAMESITE, path="/", max_age=maxAge ) @@ -73,7 +78,7 @@ def setRefreshTokenCookie(response: Response, token: str) -> None: value=token, httponly=True, secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS) - samesite="strict", + samesite=COOKIE_SAMESITE, path="/", max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 ) @@ -90,11 +95,17 @@ def clearAccessTokenCookie(response: Response) -> None: # Primary method: Raw Set-Cookie header for guaranteed deletion response.headers.append( "Set-Cookie", - f"auth_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite=Strict" + f"auth_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={COOKIE_SAMESITE_SET_COOKIE}" ) - # Fallback: Also use FastAPI's built-in method - response.delete_cookie(key="auth_token", path="/") + # Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation) + response.delete_cookie( + key="auth_token", + path="/", + secure=USE_SECURE_COOKIES, + httponly=True, + samesite=COOKIE_SAMESITE, + ) def clearRefreshTokenCookie(response: Response) -> None: @@ -108,10 +119,16 @@ def clearRefreshTokenCookie(response: Response) -> None: # Primary method: Raw Set-Cookie header for guaranteed deletion response.headers.append( "Set-Cookie", - f"refresh_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite=Strict" + f"refresh_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={COOKIE_SAMESITE_SET_COOKIE}" ) - # Fallback: Also use FastAPI's built-in method - response.delete_cookie(key="refresh_token", path="/") + # Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation) + response.delete_cookie( + key="refresh_token", + path="/", + secure=USE_SECURE_COOKIES, + httponly=True, + samesite=COOKIE_SAMESITE, + ) From ca6d8b963533e3b95a8d6197a3218191c89979fd Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 8 May 2026 14:32:08 +0200 Subject: [PATCH 13/14] fix cross site tokens --- env-gateway-int.env | 4 ++- env-gateway-prod.env | 3 +- modules/auth/jwtService.py | 67 +++++++++++++++++++++++--------------- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/env-gateway-int.env b/env-gateway-int.env index 10272b34..d22b7d2a 100644 --- a/env-gateway-int.env +++ b/env-gateway-int.env @@ -4,6 +4,8 @@ APP_ENV_TYPE = int APP_ENV_LABEL = Integration Instance APP_API_URL = https://gateway-int.poweron.swiss +# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https:// +APP_COOKIE_SECURE = true APP_KEY_SYSVAR = CONFIG_KEY APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9 APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9 @@ -19,7 +21,7 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ APP_TOKEN_EXPIRY=300 # CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss +APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net # Logging configuration APP_LOGGING_LOG_LEVEL = DEBUG diff --git a/env-gateway-prod.env b/env-gateway-prod.env index 67202109..0183ae1f 100644 --- a/env-gateway-prod.env +++ b/env-gateway-prod.env @@ -7,6 +7,7 @@ APP_KEY_SYSVAR = CONFIG_KEY APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_API_URL = https://gateway-prod.poweron.swiss +APP_COOKIE_SECURE = true # PostgreSQL DB Host DB_HOST=gateway-prod-server.postgres.database.azure.com @@ -19,7 +20,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl APP_TOKEN_EXPIRY=300 # CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss +APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net # Logging configuration APP_LOGGING_LOG_LEVEL = DEBUG diff --git a/modules/auth/jwtService.py b/modules/auth/jwtService.py index 422a4951..6ea4535d 100644 --- a/modules/auth/jwtService.py +++ b/modules/auth/jwtService.py @@ -19,15 +19,28 @@ ALGORITHM = APP_CONFIG.get("Auth_ALGORITHM") ACCESS_TOKEN_EXPIRE_MINUTES = int(APP_CONFIG.get("APP_TOKEN_EXPIRY")) REFRESH_TOKEN_EXPIRE_DAYS = int(APP_CONFIG.get("APP_REFRESH_TOKEN_EXPIRY", "7")) -# Cookie security settings - use secure cookies based on whether API uses HTTPS -# Cookies must have secure=True on HTTPS sites, secure=False on HTTP sites -APP_API_URL = APP_CONFIG.get("APP_API_URL", "http://localhost:8000") -USE_SECURE_COOKIES = APP_API_URL.startswith("https://") if APP_API_URL else False +def _cookiePolicy() -> Tuple[bool, str, str]: + """ + Return (useSecure, samesiteStarlette, samesiteSetCookieHeader). -# Cross-origin SPA (any host) + API (gateway host): SameSite=None + Secure is required so the -# browser sends cookies on credentialed XHR/fetch. HTTP localhost uses Lax (None without Secure is rejected). -COOKIE_SAMESITE = "none" if USE_SECURE_COOKIES else "lax" -COOKIE_SAMESITE_SET_COOKIE = "None" if USE_SECURE_COOKIES else "Lax" + Evaluated on each Set-Cookie so policy is not frozen at module import (config refresh / load order). + + Cross-origin SPA + API: SameSite=None and Secure=True so credentialed fetch sends cookies. + HTTP dev: Lax + Secure=False. + + APP_COOKIE_SECURE: explicit true/false (1/0, yes/no) overrides the APP_API_URL heuristic. + """ + explicit = (APP_CONFIG.get("APP_COOKIE_SECURE") or "").strip().lower() + if explicit in ("1", "true", "yes"): + useSecure = True + elif explicit in ("0", "false", "no"): + useSecure = False + else: + apiUrl = (APP_CONFIG.get("APP_API_URL") or "").strip() + useSecure = apiUrl.startswith("https://") + samesite = "none" if useSecure else "lax" + samesiteHeader = "None" if useSecure else "Lax" + return useSecure, samesite, samesiteHeader def createAccessToken(data: dict, expiresDelta: Optional[timedelta] = None) -> Tuple[str, "datetime"]: @@ -59,13 +72,14 @@ def createRefreshToken(data: dict) -> Tuple[str, "datetime"]: def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[timedelta] = None) -> None: """Set access token as httpOnly cookie.""" + useSecure, samesite, _ = _cookiePolicy() maxAge = int(expiresDelta.total_seconds()) if expiresDelta else ACCESS_TOKEN_EXPIRE_MINUTES * 60 response.set_cookie( key="auth_token", value=token, httponly=True, - secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS) - samesite=COOKIE_SAMESITE, + secure=useSecure, + samesite=samesite, path="/", max_age=maxAge ) @@ -73,12 +87,13 @@ def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[ def setRefreshTokenCookie(response: Response, token: str) -> None: """Set refresh token as httpOnly cookie.""" + useSecure, samesite, _ = _cookiePolicy() response.set_cookie( key="refresh_token", value=token, httponly=True, - secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS) - samesite=COOKIE_SAMESITE, + secure=useSecure, + samesite=samesite, path="/", max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 ) @@ -89,22 +104,22 @@ def clearAccessTokenCookie(response: Response) -> None: Clear access token cookie by setting it to expire immediately. Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility. """ - # Build secure flag based on environment - secure_flag = "; Secure" if USE_SECURE_COOKIES else "" - + useSecure, samesite, samesiteHeader = _cookiePolicy() + secure_flag = "; Secure" if useSecure else "" + # Primary method: Raw Set-Cookie header for guaranteed deletion response.headers.append( "Set-Cookie", - f"auth_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={COOKIE_SAMESITE_SET_COOKIE}" + f"auth_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={samesiteHeader}" ) - + # Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation) response.delete_cookie( key="auth_token", path="/", - secure=USE_SECURE_COOKIES, + secure=useSecure, httponly=True, - samesite=COOKIE_SAMESITE, + samesite=samesite, ) @@ -113,22 +128,22 @@ def clearRefreshTokenCookie(response: Response) -> None: Clear refresh token cookie by setting it to expire immediately. Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility. """ - # Build secure flag based on environment - secure_flag = "; Secure" if USE_SECURE_COOKIES else "" - + useSecure, samesite, samesiteHeader = _cookiePolicy() + secure_flag = "; Secure" if useSecure else "" + # Primary method: Raw Set-Cookie header for guaranteed deletion response.headers.append( "Set-Cookie", - f"refresh_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={COOKIE_SAMESITE_SET_COOKIE}" + f"refresh_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={samesiteHeader}" ) - + # Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation) response.delete_cookie( key="refresh_token", path="/", - secure=USE_SECURE_COOKIES, + secure=useSecure, httponly=True, - samesite=COOKIE_SAMESITE, + samesite=samesite, ) From df9a43c190f4f283e666d9772dfc5e0f98cf6529 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 10 May 2026 22:09:51 +0200 Subject: [PATCH 14/14] abo enterprise, ai agent fixes --- app.py | 4 + modules/aicore/aicoreModelSelector.py | 4 +- modules/datamodels/datamodelSubscription.py | 85 +++- modules/features/commcoach/mainCommcoach.py | 7 - .../features/commcoach/serviceCommcoach.py | 12 +- modules/interfaces/interfaceDbBilling.py | 55 ++- modules/interfaces/interfaceDbManagement.py | 31 +- modules/interfaces/interfaceDbSubscription.py | 31 +- modules/routes/routeSubscription.py | 172 +++++++- .../services/serviceAgent/agentLoop.py | 79 +++- .../services/serviceAgent/datamodelAgent.py | 14 +- .../renderers/documentRendererBaseTemplate.py | 24 +- .../renderers/rendererHtml.py | 27 +- .../renderers/rendererMarkdown.py | 3 +- .../renderers/rendererPdf.py | 24 +- .../renderers/rendererText.py | 7 +- .../serviceGeneration/styleDefaults.py | 10 +- .../enterpriseRenewalScheduler.py | 89 +++++ .../mainServiceSubscription.py | 378 +++++++++++++++--- .../serviceGeneration/test_style_resolver.py | 7 + 20 files changed, 902 insertions(+), 161 deletions(-) create mode 100644 modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py diff --git a/app.py b/app.py index 1f8f87f9..f5adb3d7 100644 --- a/app.py +++ b/app.py @@ -396,6 +396,10 @@ async def lifespan(app: FastAPI): from modules.shared.auditLogger import registerAuditLogCleanupScheduler registerAuditLogCleanupScheduler() + # Register enterprise subscription auto-renewal scheduler + from modules.serviceCenter.services.serviceSubscription.enterpriseRenewalScheduler import registerEnterpriseRenewalScheduler + registerEnterpriseRenewalScheduler() + # Recover background jobs that were RUNNING when the previous worker died try: from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import ( diff --git a/modules/aicore/aicoreModelSelector.py b/modules/aicore/aicoreModelSelector.py index d3df1e45..d04472cd 100644 --- a/modules/aicore/aicoreModelSelector.py +++ b/modules/aicore/aicoreModelSelector.py @@ -272,7 +272,9 @@ class ModelSelector: return 1.0 elif requestedPriority == PriorityEnum.SPEED: - return model.speedRating / 10.0 + # Scale to same magnitude as operation type (x1000) so speed + # can meaningfully influence model ranking across tiers. + return model.speedRating * 100.0 elif requestedPriority == PriorityEnum.QUALITY: return model.qualityRating / 10.0 diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py index 847285cd..4196a959 100644 --- a/modules/datamodels/datamodelSubscription.py +++ b/modules/datamodels/datamodelSubscription.py @@ -6,7 +6,7 @@ StripePlanPrice (persisted Stripe IDs per plan). State Machine: see wiki/concepts/Subscription-State-Machine.md """ -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from enum import Enum from datetime import datetime, timezone from pydantic import BaseModel, Field @@ -284,12 +284,63 @@ class MandateSubscription(PowerOnModel): json_schema_extra={"label": "Stripe-Item (Instanzen)"}, ) + # Enterprise subscription fields (custom limits, no Stripe billing) + isEnterprise: bool = Field( + default=False, + description="True for enterprise subscriptions managed by sysadmin with flat pricing", + json_schema_extra={"label": "Enterprise-Abo"}, + ) + enterpriseFlatPriceCHF: Optional[float] = Field( + None, + description="Flat price per period (CHF) for enterprise subscriptions", + json_schema_extra={"label": "Pauschale (CHF)"}, + ) + enterpriseMaxUsers: Optional[int] = Field( + None, + description="Custom user limit for enterprise (None = unlimited)", + json_schema_extra={"label": "Enterprise Max. Benutzer"}, + ) + enterpriseMaxFeatureInstances: Optional[int] = Field( + None, + description="Custom feature instance limit for enterprise (None = unlimited)", + json_schema_extra={"label": "Enterprise Max. Module"}, + ) + enterpriseMaxDataVolumeMB: Optional[int] = Field( + None, + description="Custom storage limit in MB for enterprise (None = unlimited)", + json_schema_extra={"label": "Enterprise Datenvolumen (MB)"}, + ) + enterpriseBudgetAiCHF: Optional[float] = Field( + None, + description="Fixed AI budget per period (CHF) for enterprise subscriptions", + json_schema_extra={"label": "Enterprise AI-Budget (CHF)"}, + ) + enterpriseNote: Optional[str] = Field( + None, + description="Free-text note (e.g. contract reference) for enterprise subscriptions", + json_schema_extra={"label": "Enterprise Notiz"}, + ) + # ============================================================================ # Built-in plan catalog (static, no env dependency) # ============================================================================ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = { + "ENTERPRISE": SubscriptionPlan( + planKey="ENTERPRISE", + selectableByUser=False, + title=t("Enterprise"), + description=t("Individuelles Pauschalen-Abonnement — Limiten und Preis vom Sysadmin festgelegt."), + billingPeriod=BillingPeriodEnum.NONE, + autoRenew=False, + maxUsers=None, + maxFeatureInstances=None, + includedModules=0, + maxDataVolumeMB=None, + budgetAiCHF=0.0, + budgetAiPerUserCHF=0.0, + ), "ROOT": SubscriptionPlan( planKey="ROOT", selectableByUser=False, @@ -415,3 +466,35 @@ def getPlan(planKey: str) -> Optional[SubscriptionPlan]: def _getSelectablePlans() -> List[SubscriptionPlan]: """Return plans that users can choose in the UI.""" return [p for p in BUILTIN_PLANS.values() if p.selectableByUser] + + +def getEffectiveLimits(sub: Dict[str, Any], plan: Optional[SubscriptionPlan] = None) -> Dict[str, Any]: + """Resolve effective limits for a subscription. + + For enterprise subscriptions the custom enterprise* fields on the subscription + record take precedence. For standard subscriptions the plan catalog values are + returned. Falls back to unlimited (None / 0) when neither source provides a + value.""" + if sub.get("isEnterprise"): + return { + "maxUsers": sub.get("enterpriseMaxUsers"), + "maxFeatureInstances": sub.get("enterpriseMaxFeatureInstances"), + "maxDataVolumeMB": sub.get("enterpriseMaxDataVolumeMB"), + "budgetAiCHF": sub.get("enterpriseBudgetAiCHF") or 0.0, + "includedModules": sub.get("enterpriseMaxFeatureInstances") or 0, + } + if plan: + return { + "maxUsers": plan.maxUsers, + "maxFeatureInstances": plan.maxFeatureInstances, + "maxDataVolumeMB": plan.maxDataVolumeMB, + "budgetAiCHF": plan.budgetAiCHF, + "includedModules": plan.includedModules, + } + return { + "maxUsers": None, + "maxFeatureInstances": None, + "maxDataVolumeMB": None, + "budgetAiCHF": 0.0, + "includedModules": 0, + } diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index 6beede11..999f940c 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -37,11 +37,6 @@ UI_OBJECTS = [ "label": t("Session", context="UI"), "meta": {"area": "session"} }, - { - "objectKey": "ui.feature.commcoach.dossier", - "label": t("Dossier", context="UI"), - "meta": {"area": "dossier"} - }, { "objectKey": "ui.feature.commcoach.settings", "label": t("Einstellungen", context="UI"), @@ -199,7 +194,6 @@ TEMPLATE_ROLES = [ {"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.modules", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.session", "view": True}, - {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "RESOURCE", "item": None, "view": False}, @@ -213,7 +207,6 @@ TEMPLATE_ROLES = [ {"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.modules", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.session", "view": True}, - {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "DATA", "item": "data.feature.commcoach.TrainingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 821fb291..39b96b55 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -690,7 +690,7 @@ def _buildConversationHistory(messages: List[Dict[str, Any]]) -> List[Dict[str, return history -_TTS_WORD_LIMIT = 200 +_TTS_WORD_LIMIT = 80 async def _prepareSpeechText(fullText: str, callAiFn) -> str: @@ -906,10 +906,14 @@ class CommcoachService: ) agentService = getService("agent", serviceContext) + from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum config = AgentConfig( toolSet="commcoach" if useTools else "none", maxRounds=3 if useTools else 1, temperature=0.4, + excludeAllTools=not useTools, + priority=PriorityEnum.SPEED if not useTools else None, + operationType=OperationTypeEnum.DATA_QUERY if not useTools else None, ) buildRagContextFn = _createCommcoachRagFn( @@ -989,10 +993,14 @@ class CommcoachService: ) isFirstSession = not previousSessionSummaries or len(previousSessionSummaries) == 0 + logger.info(f"Session opening {sessionId}: isFirstSession={isFirstSession}, previousSessions={len(previousSessionSummaries) if previousSessionSummaries else 0}, persona={persona.get('key') if persona else None}") if persona and persona.get("key") != "coach": personaLabel = persona.get("label", "Gesprächspartner") - openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung." + if isFirstSession: + openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung." + else: + openingUserPrompt = f"Du bist weiterhin in deiner Rolle als {personaLabel}. Der Benutzer kehrt zu einem Folgegespräch zurück. Begrüsse ihn kurz zurück, beziehe dich auf das letzte Gespräch (siehe bisherige Sessions) und knüpfe dort an. Stelle dich NICHT erneut vor." elif isFirstSession: openingUserPrompt = "Dies ist die ERSTE Session zu diesem Thema. Begrüsse den Benutzer, stelle das Thema kurz vor und stelle eine offene Einstiegsfrage. Erfinde KEINE vorherigen Gespräche oder Zusammenfassungen." else: diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index fcb559aa..25f022af 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -908,18 +908,22 @@ class BillingObjects: ) def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]: - """Debit prepay pool for new storage overage using period high-watermark (no credit on delete).""" + """Debit prepay pool for new storage overage using period high-watermark (no credit on delete). + Skipped for enterprise subscriptions (hard-block via assertCapacity instead).""" settings = self.getSettings(mandateId) if not settings: return None from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot - from modules.datamodels.datamodelSubscription import getPlan + from modules.datamodels.datamodelSubscription import getPlan, getEffectiveLimits subIface = _getSubRoot() usedMB = float(subIface.getMandateDataVolumeMB(mandateId)) sub = subIface.getOperativeForMandate(mandateId) + if sub and sub.get("isEnterprise"): + return None plan = getPlan(sub.get("planKey", "")) if sub else None - includedMB = plan.maxDataVolumeMB if plan and plan.maxDataVolumeMB is not None else None + limits = getEffectiveLimits(sub, plan) if sub else {} + includedMB = limits.get("maxDataVolumeMB") if includedMB is None: return None @@ -966,27 +970,37 @@ class BillingObjects: # Subscription AI-Budget Credit # ========================================================================= - def creditSubscriptionBudget(self, mandateId: str, planKey: str, periodLabel: str = "") -> Optional[Dict[str, Any]]: + def creditSubscriptionBudget( + self, mandateId: str, planKey: str, periodLabel: str = "", + enterpriseBudgetOverride: Optional[float] = None, + ) -> Optional[Dict[str, Any]]: """Credit AI budget to the mandate pool account. - Amount = budgetAiPerUserCHF * activeUsers (dynamic, not the static plan.budgetAiCHF). + For standard plans: amount = budgetAiPerUserCHF * activeUsers. + For enterprise: uses the fixed ``enterpriseBudgetOverride`` amount. Should be called once per billing period (initial activation + each invoice.paid). Returns the created CREDIT transaction or None if budget is 0.""" - from modules.datamodels.datamodelSubscription import getPlan + if enterpriseBudgetOverride is not None and enterpriseBudgetOverride > 0: + amount = enterpriseBudgetOverride + description = f"AI-Budget Enterprise ({planKey})" + if periodLabel: + description += f" – {periodLabel}" + else: + from modules.datamodels.datamodelSubscription import getPlan - plan = getPlan(planKey) - if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0: - return None + plan = getPlan(planKey) + if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0: + return None - from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot - subRoot = _getSubRoot() - activeUsers = max(subRoot.countActiveUsers(mandateId), 1) - amount = plan.budgetAiPerUserCHF * activeUsers + from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot + subRoot = _getSubRoot() + activeUsers = max(subRoot.countActiveUsers(mandateId), 1) + amount = plan.budgetAiPerUserCHF * activeUsers + description = f"AI-Budget ({planKey}, {activeUsers} User)" + if periodLabel: + description += f" – {periodLabel}" poolAccount = self.getOrCreateMandateAccount(mandateId) - description = f"AI-Budget ({planKey}, {activeUsers} User)" - if periodLabel: - description += f" – {periodLabel}" transaction = BillingTransaction( accountId=poolAccount["id"], @@ -998,8 +1012,8 @@ class BillingObjects: ) created = self.createTransaction(transaction) logger.info( - "AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF", - mandateId, planKey, activeUsers, amount, + "AI-Budget credited mandate=%s plan=%s amount=%.2f CHF", + mandateId, planKey, amount, ) return created @@ -1027,7 +1041,8 @@ class BillingObjects: delta > 0: user added -> CREDIT pro-rata portion delta < 0: user removed -> DEBIT pro-rata portion - """ + + Skipped for enterprise subscriptions (fixed budget, no pro-rata).""" from modules.datamodels.datamodelSubscription import getPlan plan = getPlan(planKey) @@ -1039,6 +1054,8 @@ class BillingObjects: operative = subRoot.getOperativeForMandate(mandateId) if not operative: return None + if operative.get("isEnterprise"): + return None periodStart = operative.get("currentPeriodStart") periodEnd = operative.get("currentPeriodEnd") diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index e212d502..f74de871 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -37,8 +37,6 @@ logger = logging.getLogger(__name__) managementDatabase = "poweron_management" registerDatabase(managementDatabase) -# Singleton factory for Management instances with AI service per context -_instancesManagement = {} # Custom exceptions for file handling class FileError(Exception): @@ -124,14 +122,12 @@ class ComponentObjects: return None def __del__(self): - """Cleanup method to close database connection.""" + """Release the database connector reference (shared connectors stay open).""" if hasattr(self, 'db') and self.db is not None: try: self.db.close() - except Exception as e: - logger.error(f"Error closing database connection: {e}") - - logger.debug(f"User context set: userId={self.userId}") + except Exception: + pass def _initializeDatabase(self): """Initializes the database connection directly.""" @@ -2273,10 +2269,11 @@ class ComponentObjects: def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects': """ - Returns a ComponentObjects instance. - If currentUser is provided, initializes with user context. - Otherwise, returns an instance with only database access. - + Returns a ComponentObjects instance scoped to the given user/mandate/featureInstance. + + Each call creates a lightweight instance whose DB connector is already + cached inside ``getCachedConnector``, so the overhead is minimal. + Args: currentUser: The authenticated user mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. @@ -2284,16 +2281,12 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = """ effectiveMandateId = str(mandateId) if mandateId else None effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None - - # Create new instance if not exists - if "default" not in _instancesManagement: - _instancesManagement["default"] = ComponentObjects() - - interface = _instancesManagement["default"] - + + interface = ComponentObjects() + if currentUser: interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) else: logger.info("Returning interface without user context") - + return interface \ No newline at end of file diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py index a39685fc..a0a69315 100644 --- a/modules/interfaces/interfaceDbSubscription.py +++ b/modules/interfaces/interfaceDbSubscription.py @@ -27,6 +27,7 @@ from modules.datamodels.datamodelSubscription import ( BUILTIN_PLANS, getPlan as getPlanFromCatalog, _getSelectablePlans, + getEffectiveLimits, ) logger = logging.getLogger(__name__) @@ -276,33 +277,42 @@ class SubscriptionObjects: ) plan = self.getPlan(sub.get("planKey", "")) - if not plan: - return True + limits = getEffectiveLimits(sub, plan) + isEnterprise = sub.get("isEnterprise", False) if resourceType == "users": - cap = plan.maxUsers + cap = limits["maxUsers"] if cap is None: 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) + raise SubscriptionCapacityException( + resourceType=resourceType, currentCount=current, maxAllowed=cap, + isEnterprise=isEnterprise, + ) elif resourceType == "featureInstances": - cap = plan.maxFeatureInstances + cap = limits["maxFeatureInstances"] if cap is None: 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) + raise SubscriptionCapacityException( + resourceType=resourceType, currentCount=current, maxAllowed=cap, + isEnterprise=isEnterprise, + ) elif resourceType == "dataVolumeMB": - cap = plan.maxDataVolumeMB + cap = limits["maxDataVolumeMB"] if cap is None: 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) + raise SubscriptionCapacityException( + resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap, + isEnterprise=isEnterprise, + ) return True @@ -325,10 +335,11 @@ class SubscriptionObjects: if not sub: return None plan = self.getPlan(sub.get("planKey", "")) - if not plan or not plan.maxDataVolumeMB: + limits = getEffectiveLimits(sub, plan) + limitMB = limits["maxDataVolumeMB"] + if not limitMB: return None usedMB = self.getMandateDataVolumeMB(mandateId) - limitMB = plan.maxDataVolumeMB percent = (usedMB / limitMB * 100) if limitMB > 0 else 0 if percent >= 80: return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True} diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index 22beef42..9c5ecb01 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -106,13 +106,14 @@ class SubscriptionStatusResponse(BaseModel): usage: Optional[SubscriptionUsage] = None -def _computeUsage(mandateId: str, plan) -> SubscriptionUsage: +def _computeUsage(mandateId: str, plan, operative: Optional[Dict[str, Any]] = None) -> SubscriptionUsage: """Compute current usage metrics for a mandate's subscription.""" try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelFeatures import FeatureInstance from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes + from modules.datamodels.datamodelSubscription import getEffectiveLimits rootIf = getRootInterface() @@ -128,7 +129,8 @@ def _computeUsage(mandateId: str, plan) -> SubscriptionUsage: ragBytes = aggregateMandateRagTotalBytes(mandateId) usedMB = round(ragBytes / (1024 * 1024), 2) - maxMB = plan.maxDataVolumeMB if plan else None + limits = getEffectiveLimits(operative, plan) if operative else {} + maxMB = limits.get("maxDataVolumeMB") if limits else (plan.maxDataVolumeMB if plan else None) storagePercent = round((usedMB / maxMB) * 100, 1) if maxMB else None return SubscriptionUsage( @@ -207,7 +209,7 @@ def getStatus(request: Request, context: RequestContext = Depends(getRequestCont plan = subService.getPlan(operative.get("planKey", "")) - usage = _computeUsage(mandateId, plan) + usage = _computeUsage(mandateId, plan, operative) return SubscriptionStatusResponse( active=True, @@ -451,13 +453,16 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]: sub["planTitle"] = resolveText(plan.title) if plan else planKey if sub.get("status") in operativeValues: - userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0 - instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0 userCount = userCountMap.get(mid, 0) instanceCount = instanceCountMap.get(mid, 0) - includedModules = plan.includedModules if plan else 0 - billableModules = max(0, instanceCount - includedModules) - sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2) + if sub.get("isEnterprise"): + sub["monthlyRevenueCHF"] = round(sub.get("enterpriseFlatPriceCHF") or 0, 2) + else: + userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0 + instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0 + includedModules = plan.includedModules if plan else 0 + billableModules = max(0, instanceCount - includedModules) + sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2) sub["activeUsers"] = userCount sub["activeInstances"] = instanceCount else: @@ -570,13 +575,16 @@ def _getDataVolumeUsage( ragBytes = aggregateMandateRagTotalBytes(mandateId) ragMB = round(ragBytes / (1024 * 1024), 2) + from modules.datamodels.datamodelSubscription import getEffectiveLimits + maxMB = None subIf = _getSubRootIf() operative = subIf.getOperativeForMandate(mandateId) if operative: plan = subIf.getPlan(operative.get("planKey") or "") - if plan and plan.maxDataVolumeMB is not None: - maxMB = int(plan.maxDataVolumeMB) + limits = getEffectiveLimits(operative, plan) + if limits["maxDataVolumeMB"] is not None: + maxMB = int(limits["maxDataVolumeMB"]) usedMB = ragMB percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None @@ -593,3 +601,147 @@ def _getDataVolumeUsage( "percentUsed": percentUsed, "warning": (percentUsed or 0) >= 80, } + + +# ============================================================================= +# Enterprise Subscription (SysAdmin-only) +# ============================================================================= + +class EnterpriseCreateRequest(BaseModel): + mandateId: str = Field(..., description="Target mandate ID") + startDate: float = Field(..., description="Period start (UTC unix timestamp)") + endDate: float = Field(..., description="Period end (UTC unix timestamp)") + autoRenew: bool = Field(default=False, description="Auto-renew at period end") + flatPriceCHF: float = Field(..., description="Flat price per period (CHF)") + maxUsers: Optional[int] = Field(None, description="Max users (None = unlimited)") + maxFeatureInstances: Optional[int] = Field(None, description="Max feature instances (None = unlimited)") + maxDataVolumeMB: Optional[int] = Field(None, description="Max storage in MB (None = unlimited)") + budgetAiCHF: Optional[float] = Field(None, description="Fixed AI budget per period (CHF)") + note: Optional[str] = Field(None, description="Free-text note (e.g. contract reference)") + + +class EnterpriseRenewRequest(BaseModel): + subscriptionId: str = Field(..., description="ID of the enterprise subscription to renew") + newEndDate: float = Field(..., description="New period end (UTC unix timestamp)") + autoRenew: Optional[bool] = Field(None, description="Override auto-renew flag") + flatPriceCHF: Optional[float] = Field(None, description="Override flat price (CHF)") + maxUsers: Optional[int] = Field(None, description="Override max users") + maxFeatureInstances: Optional[int] = Field(None, description="Override max feature instances") + maxDataVolumeMB: Optional[int] = Field(None, description="Override max storage (MB)") + budgetAiCHF: Optional[float] = Field(None, description="Override AI budget (CHF)") + note: Optional[str] = Field(None, description="Override note") + + +class EnterpriseUpdateRequest(BaseModel): + subscriptionId: str = Field(..., description="ID of the enterprise subscription to update") + enterpriseFlatPriceCHF: Optional[float] = Field(None, description="New flat price (CHF)") + enterpriseMaxUsers: Optional[int] = Field(None, description="New max users") + enterpriseMaxFeatureInstances: Optional[int] = Field(None, description="New max feature instances") + enterpriseMaxDataVolumeMB: Optional[int] = Field(None, description="New max storage (MB)") + enterpriseBudgetAiCHF: Optional[float] = Field(None, description="New AI budget (CHF)") + enterpriseNote: Optional[str] = Field(None, description="New note") + recurring: Optional[bool] = Field(None, description="Update auto-renew flag") + + +@router.post("/enterprise/create", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +def createEnterprise( + request: Request, + data: EnterpriseCreateRequest, + context: RequestContext = Depends(getRequestContext), +): + """SysAdmin: create an enterprise subscription with custom flat pricing and limits.""" + if not context.isPlatformAdmin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) + + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + ) + try: + subService = getSubscriptionService(context.user, data.mandateId) + return subService.createEnterprise( + mandateId=data.mandateId, + startDate=data.startDate, + endDate=data.endDate, + autoRenew=data.autoRenew, + flatPriceCHF=data.flatPriceCHF, + maxUsers=data.maxUsers, + maxFeatureInstances=data.maxFeatureInstances, + maxDataVolumeMB=data.maxDataVolumeMB, + budgetAiCHF=data.budgetAiCHF, + note=data.note, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Error creating enterprise subscription: %s", e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/enterprise/renew", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +def renewEnterprise( + request: Request, + data: EnterpriseRenewRequest, + context: RequestContext = Depends(getRequestContext), +): + """SysAdmin: renew an enterprise subscription (expire old, create new with same or overridden params).""" + if not context.isPlatformAdmin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) + + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + ) + from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface + sub = getSubRootInterface().getById(data.subscriptionId) + if not sub: + raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found")) + mandateId = sub["mandateId"] + + overrides = {} + for field in ("autoRenew", "flatPriceCHF", "maxUsers", "maxFeatureInstances", + "maxDataVolumeMB", "budgetAiCHF", "note"): + val = getattr(data, field, None) + if val is not None: + overrides[field] = val + + try: + subService = getSubscriptionService(context.user, mandateId) + return subService.renewEnterprise(data.subscriptionId, data.newEndDate, overrides or None) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Error renewing enterprise subscription %s: %s", data.subscriptionId, e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/enterprise/update", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +def updateEnterprise( + request: Request, + data: EnterpriseUpdateRequest, + context: RequestContext = Depends(getRequestContext), +): + """SysAdmin: update enterprise subscription parameters (limits, price, note).""" + if not context.isPlatformAdmin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) + + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + ) + from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface + sub = getSubRootInterface().getById(data.subscriptionId) + if not sub: + raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found")) + mandateId = sub["mandateId"] + + changes = {k: v for k, v in data.model_dump(exclude={"subscriptionId"}).items() if v is not None} + + try: + subService = getSubscriptionService(context.user, mandateId) + return subService.updateEnterprise(data.subscriptionId, changes) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Error updating enterprise subscription %s: %s", data.subscriptionId, e) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py index e1244c89..b51ffb85 100644 --- a/modules/serviceCenter/services/serviceAgent/agentLoop.py +++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py @@ -32,6 +32,44 @@ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# RAG session cache -- avoids repeated embedding + vector search per turn +# --------------------------------------------------------------------------- + +_RAG_CACHE_TTL_S = 120.0 +_RAG_CACHE_MAX_MSGS = 5 +_RAG_CACHE_MAX_ENTRIES = 200 + +_ragCache: Dict[str, Dict[str, Any]] = {} + + +async def _getOrRefreshRag( + workflowId: str, + buildRagContextFn, + *, + forceRefresh: bool = False, + **ragKwargs, +) -> str: + """Return cached RAG context or compute fresh. Thread-safe via GIL for dict ops.""" + now = time.time() + cached = _ragCache.get(workflowId) + if cached and not forceRefresh: + age = now - cached["ts"] + if age < _RAG_CACHE_TTL_S and cached["msgs"] < _RAG_CACHE_MAX_MSGS: + cached["msgs"] += 1 + return cached["ctx"] + + ragKwargs["workflowId"] = workflowId + ctx = await buildRagContextFn(**ragKwargs) + + if len(_ragCache) >= _RAG_CACHE_MAX_ENTRIES: + oldest = min(_ragCache, key=lambda k: _ragCache[k]["ts"]) + _ragCache.pop(oldest, None) + + _ragCache[workflowId] = {"ctx": ctx or "", "ts": now, "msgs": 0} + return ctx or "" + + async def runAgentLoop( prompt: str, toolRegistry: ToolRegistry, @@ -75,15 +113,20 @@ async def runAgentLoop( featureInstanceId=featureInstanceId ) - activeToolSet = config.toolSet if config else None - tools = toolRegistry.getTools(toolSet=activeToolSet) - toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet) + if config and config.excludeAllTools: + tools = [] + toolDefinitions = None + toolsText = "" + else: + activeToolSet = config.toolSet if config else None + tools = toolRegistry.getTools(toolSet=activeToolSet) + toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet) - # Text-based tool descriptions are ONLY used as fallback when native function - # calling is unavailable. Including both creates conflicting instructions - # (text ```tool_call format vs native tool_use blocks) and can cause the model - # to respond with plain text instead of actual tool calls. - toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet) + # Text-based tool descriptions are ONLY used as fallback when native function + # calling is unavailable. Including both creates conflicting instructions + # (text ```tool_call format vs native tool_use blocks) and can cause the model + # to respond with plain text instead of actual tool calls. + toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet) if systemPromptOverride: systemPrompt = systemPromptOverride @@ -100,7 +143,7 @@ async def runAgentLoop( roundStartTime = time.time() roundLog = AgentRoundLog(roundNumber=state.currentRound) - # RAG context injection (before each round for fresh relevance) + # RAG context injection (cached for conversational turns, fresh for tool turns) if buildRagContextFn: try: latestUserMsg = "" @@ -108,9 +151,12 @@ async def runAgentLoop( if msg.get("role") == "user": latestUserMsg = msg.get("content", "") break - ragContext = await buildRagContextFn( + isConversational = config and config.excludeAllTools + ragContext = await _getOrRefreshRag( + workflowId, + buildRagContextFn, + forceRefresh=not isConversational, currentPrompt=latestUserMsg or prompt, - workflowId=workflowId, userId=userId, featureInstanceId=featureInstanceId, mandateId=mandateId, @@ -166,12 +212,15 @@ async def runAgentLoop( ) # AI call + aiOptions = AiCallOptions( + operationType=config.operationType or OperationTypeEnum.AGENT, + temperature=config.temperature, + ) + if config.priority: + aiOptions.priority = config.priority aiRequest = AiCallRequest( prompt="", - options=AiCallOptions( - operationType=config.operationType or OperationTypeEnum.AGENT, - temperature=config.temperature - ), + options=aiOptions, messages=conversation.messages, tools=toolDefinitions if toolDefinitions else None, ) diff --git a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py index 16cb1964..9428af49 100644 --- a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py +++ b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py @@ -6,7 +6,7 @@ from typing import List, Dict, Any, Optional from enum import Enum from pydantic import BaseModel, Field from modules.shared.timeUtils import getUtcTimestamp -from modules.datamodels.datamodelAi import OperationTypeEnum +from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum import uuid @@ -101,6 +101,18 @@ class AgentConfig(BaseModel): "manipulate the workflow graph, not execute its actions." ), ) + excludeAllTools: bool = Field( + default=False, + description=( + "If True, send no tool definitions to the LLM at all. " + "Used for pure conversational turns (e.g. CommCoach coaching chat) " + "where tools are not needed and would only add latency." + ), + ) + priority: Optional[PriorityEnum] = Field( + default=None, + description="Model selection priority: speed | quality | cost | balanced. None = use default (balanced).", + ) class AgentState(BaseModel): diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py index d7c237fa..52daae29 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py @@ -118,12 +118,28 @@ class BaseRenderer(ABC): para = style["paragraph"] lst = style["list"] cb = style["codeBlock"] + colors = style.get("colors") if isinstance(style.get("colors"), dict) else {} + primaryColor = colors.get("primary", "#1F3864") + rawDocTitle = style.get("documentTitle") + docTitle = rawDocTitle if isinstance(rawDocTitle, dict) else {} + titleSizePt = docTitle.get("sizePt") + if titleSizePt is None: + titleSizePt = max(int(h1["sizePt"]) + 4, 26) + titleColor = docTitle.get("color", primaryColor) + titleBold = docTitle.get("weight", "bold") == "bold" + titleAlign = docTitle.get("align", "center") + if titleAlign not in ("left", "center", "right"): + titleAlign = "center" + titleSpaceBefore = docTitle.get("spaceBeforePt", 0) + titleSpaceAfter = docTitle.get("spaceAfterPt", 18) return { "title": { - "font_size": h1["sizePt"], "color": h1["color"], - "bold": h1.get("weight") == "bold", "align": "left", - "space_before": 0, - "space_after": h1.get("spaceAfterPt", 8), + "font_size": titleSizePt, + "color": titleColor, + "bold": titleBold, + "align": titleAlign, + "space_before": titleSpaceBefore, + "space_after": titleSpaceAfter, }, "heading1": { "font_size": h1["sizePt"], "color": h1["color"], diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py index b39efd50..33093b8e 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py @@ -146,8 +146,8 @@ class RendererHtml(BaseRenderer): htmlParts.append('') htmlParts.append('') - # Document header - htmlParts.append(f'

{documentTitle}

') + # Document header (not an h1 — body headings keep a single outline level scale) + htmlParts.append(f'

{documentTitle}

') # Main content htmlParts.append('
') @@ -412,16 +412,27 @@ class RendererHtml(BaseRenderer): css_parts.append(" margin: 0; padding: 20px;") css_parts.append("}") - # Document title (uses h1 style) - h1 = headings.get("h1", {}) + docTitle = style.get("documentTitle") if isinstance(style.get("documentTitle"), dict) else {} + dtSize = docTitle.get("sizePt") + if dtSize is None: + dtSize = max(headings.get("h1", {}).get("sizePt", 22) + 4, 26) + dtColor = docTitle.get("color", primaryColor) + dtWeight = docTitle.get("weight", "bold") + dtAlign = docTitle.get("align", "center") + if dtAlign not in ("left", "center", "right"): + dtAlign = "center" + dtSpaceAfter = docTitle.get("spaceAfterPt", 18) css_parts.append(".document-title {") - css_parts.append(f" font-size: {h1.get('sizePt', 24)}pt;") - css_parts.append(f" color: {h1.get('color', primaryColor)};") - css_parts.append(f" font-weight: {h1.get('weight', 'bold')};") - css_parts.append(" margin: 0 0 1em 0;") + css_parts.append(f" font-size: {dtSize}pt;") + css_parts.append(f" color: {dtColor};") + css_parts.append(f" font-weight: {dtWeight};") + css_parts.append(f" text-align: {dtAlign};") + css_parts.append(" margin: 0;") + css_parts.append(f" margin-bottom: {dtSpaceAfter}pt;") css_parts.append("}") # Headings h1-h4 + h1 = headings.get("h1", {}) for level in range(1, 5): key = f"h{level}" h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {})) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py index 1113f1a2..84649ae7 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py @@ -289,7 +289,8 @@ class RendererMarkdown(BaseRenderer): if text: level = max(1, min(6, level)) - return f"{'#' * level} {text}" + md_level = min(6, level + 1) + return f"{'#' * md_level} {text}" return "" diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index 8ba20c6a..f75a5108 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -192,6 +192,7 @@ class RendererPdf(BaseRenderer): # Extract sections and metadata from standardized schema sections = self._extractSections(json_content) + metadata = self._extractMetadata(json_content) # Create a buffer to hold the PDF buffer = io.BytesIO() @@ -204,8 +205,13 @@ class RendererPdf(BaseRenderer): else: doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18) - # Build PDF content (no cover page — body starts on page 1; filename still uses `title`) + # Body starts on page 1 — optional document title uses styles["title"] (distinct from H1) story = [] + document_title = (title or "").strip() + if not document_title and isinstance(metadata, dict): + document_title = (metadata.get("title") or "").strip() + if document_title: + story.append(self._paragraphFromInlineMarkdown(document_title, self._createDocumentTitleStyle(styles))) # Process each section (sections already extracted above) self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER") @@ -561,6 +567,22 @@ class RendererPdf(BaseRenderer): "space_before": sb, } + def _createDocumentTitleStyle(self, styles: Dict[str, Any]) -> ParagraphStyle: + """Paragraph style for the document title (metadata/doc title — not heading level 1).""" + title_style_def = styles.get("title") or {} + fs = title_style_def.get("font_size", 26) + bold = title_style_def.get("bold", True) + return ParagraphStyle( + "DocumentTitle", + fontName="Helvetica-Bold" if bold else "Helvetica", + fontSize=fs, + spaceAfter=title_style_def.get("space_after", 18), + spaceBefore=title_style_def.get("space_before", 0), + alignment=self._getAlignment(title_style_def.get("align", "center")), + textColor=self._hexToColor(title_style_def.get("color", "#1F3864")), + leading=fs * 1.25, + ) + def _createHeadingStyle(self, styles: Dict[str, Any], level: int) -> ParagraphStyle: """Create heading style from style definitions.""" heading_key = f"heading{level}" diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py index 67eab4e8..1af2aec5 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py @@ -340,11 +340,8 @@ class RendererText(BaseRenderer): except (TypeError, ValueError): level_i = 1 level_i = max(1, min(6, level_i)) - if level_i == 1: - return f"{text}\n{'=' * len(text)}" - if level_i == 2: - return f"{text}\n{'-' * len(text)}" - return f"{'#' * level_i} {text}" + md_level = min(6, level_i + 1) + return f"{'#' * md_level} {text}" except Exception as e: self.logger.warning(f"Error rendering heading: {str(e)}") diff --git a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py index 1984f18d..6d890f29 100644 --- a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py +++ b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py @@ -16,8 +16,16 @@ DEFAULT_STYLE: Dict[str, Any] = { "accent": "#2980B9", "background": "#FFFFFF", }, + "documentTitle": { + "sizePt": 28, + "weight": "bold", + "color": "#1F3864", + "spaceBeforePt": 0, + "spaceAfterPt": 18, + "align": "center", + }, "headings": { - "h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 24, "spaceAfterPt": 8}, + "h1": {"sizePt": 22, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 22, "spaceAfterPt": 8}, "h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6}, "h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4}, "h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3}, diff --git a/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py new file mode 100644 index 00000000..9db20b0f --- /dev/null +++ b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py @@ -0,0 +1,89 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Enterprise subscription auto-renewal scheduler. + +Runs daily via eventManager (APScheduler). Checks all enterprise subscriptions +with autoRenew=True whose period has ended and renews them automatically +(old -> EXPIRED, new -> ACTIVE with same duration and params, budget credit, +invoice email). +""" + +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +async def _runEnterpriseAutoRenewal() -> None: + """Scheduled task: auto-renew enterprise subscriptions whose period has ended.""" + try: + from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot + from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum + + subIface = _getSubRoot() + allSubs = subIface.listAll([SubscriptionStatusEnum.ACTIVE]) + + nowTs = datetime.now(timezone.utc).timestamp() + renewed = 0 + + for sub in allSubs: + if not sub.get("isEnterprise"): + continue + if not sub.get("recurring"): + continue + periodEnd = sub.get("currentPeriodEnd") + if not periodEnd or periodEnd > nowTs: + continue + + mandateId = sub["mandateId"] + subId = sub["id"] + periodStart = sub.get("currentPeriodStart") or sub.get("startedAt") or nowTs + periodDuration = periodEnd - periodStart + if periodDuration <= 0: + periodDuration = 30 * 86400 + newEndDate = nowTs + periodDuration + + try: + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + ) + from modules.security.rootAccess import getRootUser + rootUser = getRootUser() + subService = getSubscriptionService(rootUser, mandateId) + subService.renewEnterprise(subId, newEndDate) + renewed += 1 + logger.info( + "Auto-renewed enterprise subscription %s for mandate %s (new end: %s)", + subId, mandateId, + datetime.fromtimestamp(newEndDate, tz=timezone.utc).isoformat(), + ) + except Exception as e: + logger.error( + "Auto-renewal failed for enterprise subscription %s mandate %s: %s", + subId, mandateId, e, + ) + + if renewed: + logger.info("Enterprise auto-renewal completed: %d subscription(s) renewed", renewed) + + except Exception as e: + logger.error("Enterprise auto-renewal task failed: %s", e) + + +def registerEnterpriseRenewalScheduler() -> None: + """Register the enterprise auto-renewal cron job (daily at 06:00 UTC).""" + try: + from modules.shared.eventManagement import eventManager + + eventManager.registerCron( + jobId="enterprise_auto_renewal", + func=_runEnterpriseAutoRenewal, + cronKwargs={ + "hour": "6", + "minute": "0", + }, + ) + logger.info("Enterprise auto-renewal scheduler registered (daily at 06:00 UTC)") + + except Exception as e: + logger.error("Failed to register enterprise auto-renewal scheduler: %s", e) diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index 1a902945..0d3ae954 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -26,6 +26,7 @@ from modules.interfaces.interfaceDbSubscription import ( getInterface as getSubscriptionInterface, InvalidTransitionError, ) +from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) @@ -581,6 +582,256 @@ class SubscriptionService: def syncStripeQuantity(self, subscriptionId: str): self._interface.syncQuantityToStripe(subscriptionId) + # ========================================================================= + # Enterprise subscription management (sysadmin-only) + # ========================================================================= + + def createEnterprise( + self, mandateId: str, + startDate: float, endDate: float, autoRenew: bool, + flatPriceCHF: float, + maxUsers: Optional[int], maxFeatureInstances: Optional[int], + maxDataVolumeMB: Optional[int], budgetAiCHF: Optional[float], + note: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a new enterprise subscription with custom flat pricing and limits. + + 1. Cleanup PENDING/SCHEDULED predecessors + 2. Expire current operative subscription (no Stripe cancel) + 3. Create ACTIVE MandateSubscription with enterprise fields + 4. Credit fixed AI budget to mandate pool + 5. Send invoice email to mandate admins + """ + self._cleanupPreparatorySubscriptions(mandateId) + + currentOperative = self._interface.getOperativeForMandate(mandateId) + if currentOperative: + self._expireOperative(currentOperative["id"], mandateId) + + sub = MandateSubscription( + mandateId=mandateId, + planKey="ENTERPRISE", + status=SubscriptionStatusEnum.ACTIVE, + recurring=autoRenew, + startedAt=datetime.now(timezone.utc).timestamp(), + currentPeriodStart=startDate, + currentPeriodEnd=endDate, + isEnterprise=True, + enterpriseFlatPriceCHF=flatPriceCHF, + enterpriseMaxUsers=maxUsers, + enterpriseMaxFeatureInstances=maxFeatureInstances, + enterpriseMaxDataVolumeMB=maxDataVolumeMB, + enterpriseBudgetAiCHF=budgetAiCHF, + enterpriseNote=note, + ) + + created = self._interface.createSubscription(sub) + self.invalidateCache(mandateId) + + self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erstaktivierung") + _notifyEnterpriseInvoice(mandateId, created) + + logger.info("Enterprise subscription created for mandate %s: id=%s", mandateId, created["id"]) + return created + + def renewEnterprise( + self, subscriptionId: str, newEndDate: float, + overrides: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Renew an enterprise subscription: expire old, create new with same or overridden params. + + 1. Load + validate old subscription + 2. Expire old subscription + 3. Create new ACTIVE subscription (clone params, apply overrides) + 4. Credit AI budget + 5. Send invoice email + """ + oldSub = self._interface.getById(subscriptionId) + if not oldSub: + raise ValueError(f"Subscription {subscriptionId} not found") + if not oldSub.get("isEnterprise"): + raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription") + + mandateId = oldSub["mandateId"] + self._interface.forceExpire(subscriptionId) + self.invalidateCache(mandateId) + + overrides = overrides or {} + nowTs = datetime.now(timezone.utc).timestamp() + startDate = nowTs + autoRenew = overrides.get("autoRenew", oldSub.get("recurring", False)) + flatPriceCHF = overrides.get("flatPriceCHF", oldSub.get("enterpriseFlatPriceCHF")) + maxUsers = overrides.get("maxUsers", oldSub.get("enterpriseMaxUsers")) + maxFeatureInstances = overrides.get("maxFeatureInstances", oldSub.get("enterpriseMaxFeatureInstances")) + maxDataVolumeMB = overrides.get("maxDataVolumeMB", oldSub.get("enterpriseMaxDataVolumeMB")) + budgetAiCHF = overrides.get("budgetAiCHF", oldSub.get("enterpriseBudgetAiCHF")) + note = overrides.get("note", oldSub.get("enterpriseNote")) + + sub = MandateSubscription( + mandateId=mandateId, + planKey="ENTERPRISE", + status=SubscriptionStatusEnum.ACTIVE, + recurring=autoRenew, + startedAt=nowTs, + currentPeriodStart=startDate, + currentPeriodEnd=newEndDate, + isEnterprise=True, + enterpriseFlatPriceCHF=flatPriceCHF, + enterpriseMaxUsers=maxUsers, + enterpriseMaxFeatureInstances=maxFeatureInstances, + enterpriseMaxDataVolumeMB=maxDataVolumeMB, + enterpriseBudgetAiCHF=budgetAiCHF, + enterpriseNote=note, + ) + + created = self._interface.createSubscription(sub) + self.invalidateCache(mandateId) + + self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erneuerung") + _notifyEnterpriseInvoice(mandateId, created) + + logger.info( + "Enterprise subscription renewed for mandate %s: old=%s -> new=%s", + mandateId, subscriptionId, created["id"], + ) + return created + + def updateEnterprise(self, subscriptionId: str, changes: Dict[str, Any]) -> Dict[str, Any]: + """Update enterprise subscription parameters (limits, note, flat price). + + Only enterprise-specific fields are allowed. No status change.""" + sub = self._interface.getById(subscriptionId) + if not sub: + raise ValueError(f"Subscription {subscriptionId} not found") + if not sub.get("isEnterprise"): + raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription") + + allowedFields = { + "enterpriseFlatPriceCHF", "enterpriseMaxUsers", "enterpriseMaxFeatureInstances", + "enterpriseMaxDataVolumeMB", "enterpriseBudgetAiCHF", "enterpriseNote", + "recurring", + } + updateData = {k: v for k, v in changes.items() if k in allowedFields} + if not updateData: + raise ValueError("No valid enterprise fields to update") + + result = self._interface.updateFields(subscriptionId, updateData) + self.invalidateCache(sub["mandateId"]) + logger.info("Enterprise subscription %s updated: %s", subscriptionId, list(updateData.keys())) + return result + + def _creditEnterpriseBudget( + self, mandateId: str, budgetAiCHF: Optional[float], periodLabel: str, + ) -> None: + if not budgetAiCHF or budgetAiCHF <= 0: + return + try: + from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot + _getBillingRoot().creditSubscriptionBudget( + mandateId, "ENTERPRISE", periodLabel=periodLabel, + enterpriseBudgetOverride=budgetAiCHF, + ) + except Exception as e: + logger.error("Enterprise budget credit failed for mandate %s: %s", mandateId, e) + + +# ============================================================================ +# Enterprise Invoice Email +# ============================================================================ + +def _notifyEnterpriseInvoice(mandateId: str, subRecord: Dict[str, Any]) -> None: + """Send enterprise invoice email to mandate admins.""" + try: + from modules.shared.notifyMandateAdmins import notifyMandateAdmins + + rawHtml = _buildEnterpriseInvoiceHtml(subRecord) + flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0 + notifyMandateAdmins( + mandateId, + t("[PowerOn] Enterprise-Abonnement — Rechnung") + f" (CHF {flatPrice:,.2f})", + t("Enterprise-Abonnement — Rechnung"), + [ + t("Das Enterprise-Abonnement wurde aktiviert."), + t("Bitte begleichen Sie den Rechnungsbetrag innert 10 Tagen."), + t("Details zum Abonnement finden Sie unter Billing-Verwaltung."), + ], + rawHtmlBlock=rawHtml, + ) + except Exception as e: + logger.error("Enterprise invoice email failed for mandate %s: %s", mandateId, e) + + +def _buildEnterpriseInvoiceHtml(subRecord: Dict[str, Any]) -> str: + """Build HTML invoice summary for enterprise subscription email.""" + flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0 + maxUsers = subRecord.get("enterpriseMaxUsers") + maxFeatures = subRecord.get("enterpriseMaxFeatureInstances") + maxStorageMB = subRecord.get("enterpriseMaxDataVolumeMB") + budgetAi = subRecord.get("enterpriseBudgetAiCHF") + note = subRecord.get("enterpriseNote") or "" + periodStart = subRecord.get("currentPeriodStart") + periodEnd = subRecord.get("currentPeriodEnd") + + def _chf(amount: float) -> str: + return f"CHF {amount:,.2f}".replace(",", "'") + + 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 = "" + if maxUsers is not None: + detailRows += ( + f'{t("Benutzer")}' + f'max. {maxUsers}' + ) + if maxFeatures is not None: + detailRows += ( + f'{t("Module")}' + f'max. {maxFeatures}' + ) + if maxStorageMB is not None: + storageLabel = f"{maxStorageMB} MB" if maxStorageMB < 1024 else f"{maxStorageMB / 1024:.1f} GB" + detailRows += ( + f'{t("Datenvolumen")}' + f'max. {storageLabel}' + ) + if budgetAi is not None and budgetAi > 0: + detailRows += ( + f'{t("KI-Budget")}' + f'{_chf(budgetAi)}' + ) + + noteHtml = "" + if note: + import html as htmlmod + noteHtml = ( + f'

' + f'{t("Notiz")}: {htmlmod.escape(note)}

' + ) + + return ( + f'' + f'' + f'' + f'' + f'' + f'' + f'{detailRows}' + f'' + f'' + f'' + f'' + f'' + f'
{t("Zeitraum")}{_fmtDate(periodStart)} – {_fmtDate(periodEnd)}
{t("Pauschale")}' + f'{_chf(flatPrice)}
' + f'

' + f'{t("Zahlungsfrist")}: {t("10 Tage")}

' + f'{noteHtml}' + ) + # ============================================================================ # Notifications @@ -608,66 +859,66 @@ def _notifySubscriptionChange( templates: Dict[str, Dict[str, Any]] = { "activated": { - "subject": f"[PowerOn] Abonnement aktiviert — {planLabel}", - "headline": "Abonnement aktiviert", + "subject": f"[PowerOn] {t('Abonnement aktiviert')} — {planLabel}", + "headline": t("Abonnement aktiviert"), "paragraphs": [ p for p in [ - f"Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.", + t("Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.").format(planLabel=planLabel), platformHint, - "Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung › Abonnement einsehen und verwalten.", + t("Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung › Abonnement einsehen und verwalten."), ] if p ], }, "cancelled": { - "subject": f"[PowerOn] Abonnement gekündigt — {planLabel}", - "headline": "Abonnement gekündigt", + "subject": f"[PowerOn] {t('Abonnement gekündigt')} — {planLabel}", + "headline": t("Abonnement gekündigt"), "paragraphs": [ p for p in [ - f"Das Abonnement «{planLabel}» wurde gekündigt.", + t("Das Abonnement «{planLabel}» wurde gekündigt.").format(planLabel=planLabel), platformHint, - "Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen.", + t("Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen."), ] if p ], }, "force_cancelled": { - "subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}", - "headline": "Abonnement sofort beendet", + "subject": f"[PowerOn] {t('Abonnement sofort beendet')} — {planLabel}", + "headline": t("Abonnement sofort beendet"), "paragraphs": [ p for p in [ - f"Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.", + t("Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.").format(planLabel=planLabel), platformHint, - "Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support.", + t("Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support."), ] if p ], }, "trial_expired": { - "subject": "[PowerOn] Testphase abgelaufen", - "headline": "Testphase abgelaufen", + "subject": f"[PowerOn] {t('Testphase abgelaufen')}", + "headline": t("Testphase abgelaufen"), "paragraphs": [ p for p in [ - "Die kostenlose Testphase ist abgelaufen.", + t("Die kostenlose Testphase ist abgelaufen."), platformHint, - "Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement, damit der Zugang nicht unterbrochen wird.", + t("Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement, damit der Zugang nicht unterbrochen wird."), ] if p ], }, "payment_failed": { - "subject": f"[PowerOn] Zahlung fehlgeschlagen — {planLabel}", - "headline": "Zahlung fehlgeschlagen", + "subject": f"[PowerOn] {t('Zahlung fehlgeschlagen')} — {planLabel}", + "headline": t("Zahlung fehlgeschlagen"), "paragraphs": [ p for p in [ - f"Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.", + t("Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.").format(planLabel=planLabel), platformHint, - "Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung.", + t("Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung."), ] if p ], }, } tpl = templates.get(event, { - "subject": f"[PowerOn] Abonnement-Änderung — {planLabel}", - "headline": "Abonnement-Änderung", - "paragraphs": [f"Änderung am Abonnement «{planLabel}»."], + "subject": f"[PowerOn] {t('Abonnement-Änderung')} — {planLabel}", + "headline": t("Abonnement-Änderung"), + "paragraphs": [t("Änderung am Abonnement «{planLabel}».").format(planLabel=planLabel)], }) notifyMandateAdmins( @@ -699,7 +950,7 @@ def _buildInvoiceSummaryHtml( instanceTotal = billableModules * instancePrice netTotal = userTotal + instanceTotal - periodLabel = {"MONTHLY": "Monatlich", "YEARLY": "Jährlich"}.get(plan.billingPeriod, plan.billingPeriod) + periodLabel = {"MONTHLY": t("Monatlich"), "YEARLY": t("Jährlich")}.get(plan.billingPeriod, plan.billingPeriod) def _chf(amount: float) -> str: return f"CHF {amount:,.2f}".replace(",", "'") @@ -707,13 +958,13 @@ def _buildInvoiceSummaryHtml( rows = "" if userPrice > 0: rows += ( - f'Benutzer-Lizenzen' + f'{t("Benutzer-Lizenzen")}' f'{userCount} × {_chf(userPrice)}' f'{_chf(userTotal)}\n' ) if instancePrice > 0 and billableModules > 0: rows += ( - f'Module ({instanceCount} total, {plan.includedModules} inkl.)' + f'{t("Module")} ({instanceCount} total, {plan.includedModules} {t("inkl.")})' f'{billableModules} × {_chf(instancePrice)}' f'{_chf(instanceTotal)}\n' ) @@ -733,7 +984,7 @@ def _buildInvoiceSummaryHtml( invoiceLink = ( f'

' f'' - f'Vollständige Rechnung mit MwSt-Ausweis anzeigen

\n' + f'{t("Vollständige Rechnung mit MwSt-Ausweis anzeigen")}

\n' ) except Exception as e: logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e) @@ -741,13 +992,13 @@ def _buildInvoiceSummaryHtml( return ( f'' f'' - f'' - f'' - f'' + f'' + f'' + f'' f'' f'{rows}' f'' - f'' + f'' f'' f'' f'' @@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") -> parts.append( f'

' f'' - f'Letzte Stripe-Rechnung anzeigen

' + f'{t("Letzte Stripe-Rechnung anzeigen")}

' ) except Exception as e: logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e) @@ -822,7 +1073,7 @@ class SubscriptionInactiveException(Exception): self.mandateId = mandateId self.reason = _subscriptionReasonForStatus(status) self.userAction = _subscriptionUserActionForStatus(status) - self.message = message or ( + self.message = message or t( "Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing." ) super().__init__(self.message) @@ -837,47 +1088,62 @@ class SubscriptionInactiveException(Exception): return out -_SUBSCRIPTION_LIMITS_UI_HINT_DE = ( - " Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: " - "Menü «Administration» → «Billing» → Registerkarte «Abonnement»." -) +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): + 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 = ( - f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} " - f"Benutzer zulässig (derzeit {currentCount}). " - f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden." - ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE + 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 = ( - f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). " - f"Bitte Abonnement erweitern oder ein Modul entfernen." - ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE + 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 = ( - f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht " - f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen." - ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE + 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 = ( - f"Abonnement-Limit überschritten (Ressource «{resourceType}»: " - f"aktuell {currentCount}, erlaubt {maxAllowed})." - ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE + 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": SUBSCRIPTION_USER_ACTION_UPGRADE, + "message": self.message, "userAction": action, "subscriptionUiPath": "/admin/billing?tab=subscription", } diff --git a/tests/serviceGeneration/test_style_resolver.py b/tests/serviceGeneration/test_style_resolver.py index 6b2b649a..06f907ef 100644 --- a/tests/serviceGeneration/test_style_resolver.py +++ b/tests/serviceGeneration/test_style_resolver.py @@ -37,3 +37,10 @@ def test_full_style_passthrough(): result = resolveStyle(custom) assert result["fonts"]["primary"] == "Helvetica" assert result["fonts"]["monospace"] == "Monaco" + + +def test_override_document_title_partial_merge(): + result = resolveStyle({"documentTitle": {"sizePt": 32}}) + assert result["documentTitle"]["sizePt"] == 32 + assert result["documentTitle"]["align"] == "center" + assert result["headings"]["h1"]["sizePt"] == DEFAULT_STYLE["headings"]["h1"]["sizePt"]
PositionMenge × PreisTotal{t("Position")}{t("Menge")} × {t("Preis")}{t("Total")}
Netto-Total ({periodLabel}){t("Netto-Total")} ({periodLabel}){_chf(netTotal)}