From 0a5fa20cb8fdc8b318cbe073d4aac97a23e73970 Mon Sep 17 00:00:00 2001 From: ValueOn AG
{html.escape(intro)}
' if intro else "" + coreTopicHtml = f'{html.escape(coreTopic)}
' if coreTopic else "" + + sectionsHtml = "".join([ + _renderSection("Kernbotschaft", introHtml), + _renderSection("Kernthema", coreTopicHtml), + _renderSection("Erkenntnisse", _renderList(insights)), + _renderSection("Nächste Schritte", _renderList(nextSteps)), + _renderSection("Fortschritt", _renderList(progress)), + ]) + + return ( + ''
+ f'{html.escape(headline)}' + f'Thema: {html.escape(contextTitle)} ' + ' |
{summary}
" - htmlMessage = _wrapEmailHtml(contentHtml) + mandateName = _resolveMandateName(self.mandateId) + contentHtml = _buildSummaryEmailBlock(emailData, summary, contextTitle) + htmlMessage = _renderHtmlEmail( + "Coaching-Session Zusammenfassung", + [ + f'Thema: {contextTitle}', + "Hier ist die kompakte Zusammenfassung deiner abgeschlossenen Session.", + ], + mandateName, + footerNote="Diese Zusammenfassung wurde automatisch aus deiner Coaching-Session erstellt.", + rawHtmlBlock=contentHtml, + ) messaging.send("email", user.email, subject, htmlMessage) interface.updateSession(session.get("id"), {"emailSent": True}) diff --git a/modules/features/commcoach/serviceCommcoachAi.py b/modules/features/commcoach/serviceCommcoachAi.py index 97deb373..8b916005 100644 --- a/modules/features/commcoach/serviceCommcoachAi.py +++ b/modules/features/commcoach/serviceCommcoachAi.py @@ -168,29 +168,18 @@ Handlungsprinzip: - Wenn der Benutzer dich bittet, etwas zu erstellen (Dokument, Präsentation, Checkliste, Plan), dann TU ES SOFORT. Frage NICHT nochmals nach Bestätigung. - Verwende alle verfügbaren Informationen aus dem Chat-Verlauf, den Dokumenten und dem Kontext. - Wenn der Benutzer sagt "erstelle", "mach", "schreib", dann liefere das fertige Ergebnis — keine Aufzählung von Punkten, die du "gleich umsetzen wirst". +- Dir wird automatisch relevanter Kontext aus früheren Sessions bereitgestellt (Relevant Knowledge). Nutze diesen für Kontinuität und Bezugnahme auf frühere Gespräche. Antwortformat: -Du antwortest IMMER als reines JSON-Objekt mit exakt diesen Feldern: -{"text": "...", "speech": "...", "documents": []} +- Antworte direkt als Freitext (KEIN JSON). Markdown-Formatierung ist erlaubt. +- Halte Antworten gesprächig und kurz (2-6 Sätze im Normalfall), wie in einem echten Coaching-Gespräch. +- Bei komplexen Themen oder wenn der Benutzer Details anfragt, darf die Antwort ausführlicher sein. +- Dein Text wird sowohl angezeigt als auch vorgelesen – schreibe daher natürlich und gut sprechbar. -"text": Dein schriftlicher Chat-Text. Details, Struktur, Übungen, Beispiele. Markdown-Formatierung erlaubt. -"speech": Dein gesprochener Kommentar. Natürlich, wie ein Gespräch. Fasse zusammen, kommentiere, motiviere, stelle Fragen. Lies NICHT den Text vor, ergänze ihn mündlich. 2-4 Sätze, reiner Redetext ohne Formatierung. -"documents": Dokumente die der Benutzer aufbewahren kann. Erstelle ein Dokument wenn: der Benutzer explizit darum bittet, du strukturierte Inhalte lieferst, oder Material zum Aufbewahren sinnvoll ist. Wenn keine: leeres Array []. - -Dokument-Format: -{"title": "Dateiname_mit_Extension.html", "content": "...vollstaendiger Inhalt..."} -- Der Title IST der Dateiname inkl. Extension (.html, .md, .txt etc.) -- Fuer HTML-Dokumente: Erstelle VOLLSTAENDIGES, professionell gestyltes HTML mit inline CSS. Kein Markdown, sondern fertiges HTML mit Farben, Layout, Typografie. -- Fuer andere Dokumente: Verwende Markdown. -- WICHTIG: Der Content muss VOLLSTAENDIG und AUSFUEHRLICH sein. Keine Platzhalter, keine "hier kommt..."-Abschnitte. Schreibe echte, detaillierte Inhalte basierend auf allen verfuegbaren Informationen aus dem Chat und den Dokumenten. -- Laengenbeschraenkung fuer Dokumente: KEINE. Schreibe so viel wie noetig fuer ein vollstaendiges Ergebnis. - -Kanalverteilung: -- Fakten, Listen, Übungen -> text -- Empathie, Einordnung, Nachfragen -> speech -- Erstellte Dateien, Materialien zum Aufbewahren -> documents - -WICHTIG: Antworte NUR mit dem JSON-Objekt. Kein Text vor oder nach dem JSON.""" +Tool-Nutzung: +- Du hast Zugriff auf Tools (Dateien lesen, Web-Suche, Datenquellen abfragen) wenn der Benutzer Dateien/Quellen angehängt hat oder Recherche benötigt. +- Nutze Tools NUR wenn nötig. Für normales Coaching-Gespräch: antworte direkt ohne Tools. +- Wenn du ein Tool nutzt, erkläre kurz was du tust.""" if contextDescription: prompt += f"\n\nKontext-Beschreibung: {contextDescription}" @@ -279,7 +268,7 @@ Fuer ein NEUES Dokument: {"title": "...", "content": "...Inhalt..."}""" def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str: - """Build a prompt to generate a session summary as JSON with plain text and styled HTML email.""" + """Build a prompt to generate a session summary plus structured email content.""" conversation = "" for msg in messages: role = "Benutzer" if msg.get("role") == "user" else "Coach" @@ -287,27 +276,33 @@ def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str return f"""Erstelle eine Zusammenfassung dieser Coaching-Session zum Thema "{contextTitle}". -Antworte AUSSCHLIESSLICH als JSON mit zwei Feldern: +Antworte AUSSCHLIESSLICH als JSON im folgenden Format: {{ - "summary": "Kompakte Zusammenfassung als Plaintext (fuer Anzeige in der App). Struktur: 1. Kernthema, 2. Erkenntnisse, 3. Naechste Schritte, 4. Fortschritt.", - "emailHtml": "fuer Fliesstext (color: #374151; line-height: 1.65; font-size: 15px) -- Verwende
| '
+ ' Aktive Coaching-Themen '
+ f' |
| '
+ ' Dein Rhythmus '
+ f'Aktueller Streak: '
+ f'{int(streakDays or 0)} Tage '
+ ' |
Du hast aktive Coaching-Themen: {contextList}
-Nimm dir 10 Minuten fuer eine kurze Session. Konsistenz ist der Schluessel zu Fortschritt.
-Dein aktueller Streak: {profile.get('streakDays', 0)} Tage
- """ + subject = "Dein tägliches Coaching wartet" + mandateName = _resolveMandateName(profile.get("mandateId")) + htmlMessage = _renderHtmlEmail( + "Zeit für dein tägliches Coaching", + [ + f"Du hast aktuell {len(contexts)} aktive Coaching-Themen.", + "Schon 10 Minuten reichen oft, um einen Gedanken zu klären, eine nächste Aktion festzulegen oder ein Gespräch vorzubereiten.", + f"Im Fokus: {contextList}", + ], + mandateName, + footerNote="Diese Erinnerung wurde automatisch auf Basis deiner CommCoach-Einstellungen versendet.", + rawHtmlBlock=_buildReminderHtmlBlock(contextTitles, int(profile.get("streakDays", 0) or 0)), + ) - messaging.send("email", user.email, subject, message) + messaging.send("email", user.email, subject, htmlMessage) sentCount += 1 except Exception as e: logger.warning(f"Failed to send reminder to user {profile.get('userId')}: {e}") diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py index f0aedc87..a859ffa7 100644 --- a/modules/interfaces/interfaceAiObjects.py +++ b/modules/interfaces/interfaceAiObjects.py @@ -134,7 +134,7 @@ class AiObjects: logger.info(f"Attempting AI call with model: {model.name} (attempt {attempt + 1}/{len(failoverModelList)})") if request.messages: - response = await self._callWithMessages(model, request.messages, options, request.tools) + response = await self._callWithMessages(model, request.messages, options, request.tools, toolChoice=request.toolChoice) else: response = await self._callWithModel(model, prompt, context, options) @@ -149,7 +149,7 @@ class AiObjects: await asyncio.sleep(retryAfter + 0.5) try: if request.messages: - response = await self._callWithMessages(model, request.messages, options, request.tools) + response = await self._callWithMessages(model, request.messages, options, request.tools, toolChoice=request.toolChoice) else: response = await self._callWithModel(model, prompt, context, options) logger.info(f"AI call successful with {model.name} after rate-limit retry") @@ -288,7 +288,8 @@ class AiObjects: async def _callWithMessages(self, model: AiModel, messages: List[Dict[str, Any]], options: AiCallOptions = None, - tools: List[Dict[str, Any]] = None) -> AiCallResponse: + tools: List[Dict[str, Any]] = None, + toolChoice: Any = None) -> AiCallResponse: """Call a model with pre-built messages (agent mode). Supports tools for native function calling.""" import json as _json @@ -302,7 +303,8 @@ class AiObjects: messages=messages, model=model, options=options or {}, - tools=tools + tools=tools, + toolChoice=toolChoice, ) modelResponse = await model.functionCall(modelCall) @@ -379,7 +381,7 @@ class AiObjects: for attempt, model in enumerate(failoverModelList): try: logger.info(f"Streaming AI call with model: {model.name} (attempt {attempt + 1})") - async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools): + async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools, toolChoice=request.toolChoice): yield chunk return @@ -390,7 +392,7 @@ class AiObjects: logger.info(f"Rate limit on {model.name}, waiting {retryAfter:.1f}s before retry") await asyncio.sleep(retryAfter + 0.5) try: - async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools): + async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools, toolChoice=request.toolChoice): yield chunk return except Exception as retryErr: @@ -421,6 +423,7 @@ class AiObjects: async def _callWithMessagesStream( self, model: AiModel, messages: List[Dict[str, Any]], options: AiCallOptions = None, tools: List[Dict[str, Any]] = None, + toolChoice: Any = None, ) -> AsyncGenerator[Union[str, AiCallResponse], None]: """Stream a model call. Yields str deltas, then final AiCallResponse with billing.""" from modules.datamodels.datamodelAi import AiModelCall, AiModelResponse @@ -429,7 +432,7 @@ class AiObjects: startTime = time.time() if not model.functionCallStream: - response = await self._callWithMessages(model, messages, options, tools) + response = await self._callWithMessages(model, messages, options, tools, toolChoice=toolChoice) if response.content: yield response.content yield response @@ -438,6 +441,7 @@ class AiObjects: modelCall = AiModelCall( messages=messages, model=model, options=options or {}, tools=tools, + toolChoice=toolChoice, ) finalModelResponse = None diff --git a/modules/routes/routeVoiceGoogle.py b/modules/routes/routeVoiceGoogle.py index 1c796361..309e59bb 100644 --- a/modules/routes/routeVoiceGoogle.py +++ b/modules/routes/routeVoiceGoogle.py @@ -444,7 +444,7 @@ async def health_check(currentUser: User = Depends(getCurrentUser)): async def get_voice_settings(currentUser: User = Depends(getCurrentUser)): """Get voice settings for the current user (reads from UserVoicePreferences).""" from modules.datamodels.datamodelUam import UserVoicePreferences - from modules.security.rootAccess import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() userId = str(currentUser.id) @@ -464,7 +464,7 @@ async def save_voice_settings( ): """Save voice settings for the current user (writes to UserVoicePreferences).""" from modules.datamodels.datamodelUam import UserVoicePreferences, _normalizeTtsVoiceMap - from modules.security.rootAccess import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() userId = str(currentUser.id) diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py index c196d237..fa76141d 100644 --- a/modules/serviceCenter/services/serviceAgent/agentLoop.py +++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py @@ -48,6 +48,7 @@ async def runAgentLoop( conversationHistory: List[Dict[str, Any]] = None, persistRoundMemoryFn: Callable[..., Awaitable[None]] = None, getExternalMemoryKeysFn: Callable[[], List[str]] = None, + systemPromptOverride: str = None, ) -> AsyncGenerator[AgentEvent, None]: """Run the agent loop. Yields AgentEvent for each step (SSE-ready). @@ -74,16 +75,20 @@ async def runAgentLoop( featureInstanceId=featureInstanceId ) - tools = toolRegistry.getTools() - toolDefinitions = toolRegistry.formatToolsForFunctionCalling() + 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() + toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet) - systemPrompt = buildSystemPrompt(tools, toolsText, userLanguage=userLanguage) + if systemPromptOverride: + systemPrompt = systemPromptOverride + else: + systemPrompt = buildSystemPrompt(tools, toolsText, userLanguage=userLanguage) conversation = ConversationManager(systemPrompt) if conversationHistory: conversation.loadHistory(conversationHistory) @@ -168,7 +173,7 @@ async def runAgentLoop( temperature=config.temperature ), messages=conversation.messages, - tools=toolDefinitions + tools=toolDefinitions if toolDefinitions else None, ) try: diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 08950ea3..b370b827 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -132,6 +132,8 @@ class AgentService: additionalTools: List[Dict[str, Any]] = None, userLanguage: str = "", conversationHistory: List[Dict[str, Any]] = None, + buildRagContextFn: Callable = None, + systemPromptOverride: str = None, ) -> AsyncGenerator[AgentEvent, None]: """Run an agent with the given prompt and tools. @@ -144,6 +146,8 @@ class AgentService: additionalTools: Extra tool definitions to register dynamically userLanguage: ISO 639-1 language code; falls back to user.language from profile conversationHistory: Prior messages for follow-up context + buildRagContextFn: Optional custom RAG context builder (overrides default) + systemPromptOverride: Optional system prompt override (replaces generated prompt) Yields: AgentEvent for each step (SSE-ready) @@ -163,7 +167,8 @@ class AgentService: aiCallFn = self._createAiCallFn() aiCallStreamFn = self._createAiCallStreamFn() getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId) - buildRagContextFn = self._createBuildRagContextFn() + if buildRagContextFn is None: + buildRagContextFn = self._createBuildRagContextFn() persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId) getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId) @@ -183,6 +188,7 @@ class AgentService: conversationHistory=conversationHistory, persistRoundMemoryFn=persistRoundMemoryFn, getExternalMemoryKeysFn=getExternalMemoryKeysFn, + systemPromptOverride=systemPromptOverride, ): if event.type == AgentEventTypeEnum.AGENT_SUMMARY: await self._persistTrace(workflowId, event.data or {}) @@ -2610,54 +2616,54 @@ def _registerCoreTools(registry: ToolRegistry, services): if not voiceName: try: from modules.datamodels.datamodelUam import UserVoicePreferences - from modules.security.rootAccess import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface userId = context.get("userId", "") if userId: rootIf = getRootInterface() prefRecords = rootIf.db.getRecordset( UserVoicePreferences, - recordFilter={"userId": userId, "mandateId": mandateId} + recordFilter={"userId": userId} ) - if not prefRecords and mandateId: - prefRecords = rootIf.db.getRecordset( - UserVoicePreferences, - recordFilter={"userId": userId} - ) if prefRecords: - vs = prefRecords[0] if isinstance(prefRecords[0], dict) else prefRecords[0].model_dump() if hasattr(prefRecords[0], "model_dump") else prefRecords[0] - voiceMap = vs.get("ttsVoiceMap", {}) or {} - if isinstance(voiceMap, dict) and voiceMap: - selectedKey = None - selectedVoiceEntry = None - baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else "" + allPrefs = [ + r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else r + for r in prefRecords + ] + _mid = str(mandateId or "").strip() + scopedPref = next((p for p in allPrefs if str(p.get("mandateId") or "").strip() == _mid), None) + globalPref = next((p for p in allPrefs if not str(p.get("mandateId") or "").strip()), None) - if isinstance(language, str) and language in voiceMap: - selectedKey = language - selectedVoiceEntry = voiceMap[language] + def _resolveVoiceFromMap(prefDict, lang): + vm = (prefDict or {}).get("ttsVoiceMap", {}) or {} + if not isinstance(vm, dict) or not vm: + return None + baseLang = lang.split("-")[0].lower() if isinstance(lang, str) and lang else "" + langNorm = str(lang or "").strip() + if langNorm in vm: + entry = vm[langNorm] + return entry.get("voiceName") if isinstance(entry, dict) else entry + if baseLang and baseLang in vm: + entry = vm[baseLang] + return entry.get("voiceName") if isinstance(entry, dict) else entry + if baseLang: + for mk, mv in vm.items(): + mkn = str(mk).lower() + if mkn == baseLang or mkn.startswith(f"{baseLang}-"): + return mv.get("voiceName") if isinstance(mv, dict) else mv + return None - if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap: - selectedKey = baseLanguage - selectedVoiceEntry = voiceMap[baseLanguage] - - if selectedVoiceEntry is None and baseLanguage: - for mapKey, mapValue in voiceMap.items(): - mapKeyNorm = str(mapKey).lower() - if mapKeyNorm == baseLanguage or mapKeyNorm.startswith(f"{baseLanguage}-"): - selectedKey = str(mapKey) - selectedVoiceEntry = mapValue - break - - if selectedVoiceEntry is not None: - voiceName = ( - selectedVoiceEntry.get("voiceName") - if isinstance(selectedVoiceEntry, dict) - else selectedVoiceEntry - ) - logger.info( - f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')" - ) - if not voiceName and vs.get("ttsVoice") and vs.get("ttsLanguage") == language: - voiceName = vs["ttsVoice"] + voiceName = ( + _resolveVoiceFromMap(scopedPref, language) + or _resolveVoiceFromMap(globalPref, language) + or _resolveVoiceFromMap(allPrefs[0], language) + ) + if not voiceName: + for candidate in [globalPref, scopedPref, allPrefs[0]]: + if candidate and candidate.get("ttsVoice") and candidate.get("ttsLanguage") == language: + voiceName = candidate["ttsVoice"] + break + if voiceName: + logger.info(f"textToSpeech: using configured voice '{voiceName}' for language '{language}'") except Exception as prefErr: logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}") @@ -3416,3 +3422,21 @@ def _registerCoreTools(registry: ToolRegistry, services): }, readOnly=True, ) + + # Tag core-only tools so restricted toolSets (e.g. "commcoach") exclude them. + # Tools NOT in this set remain toolSet=None → available to ALL sets. + _CORE_ONLY_TOOLS = { + "listFiles", "listFolders", "tagFile", "moveFile", "createFolder", + "writeFile", "deleteFile", "renameFile", "translateText", + "deleteFolder", "renameFolder", "moveFolder", "copyFile", "replaceInFile", + "listConnections", "uploadToExternal", "sendMail", "downloadFromDataSource", + "browseContainer", "readContentObjects", "extractContainerItem", + "summarizeContent", "describeImage", "renderDocument", + "textToSpeech", "generateImage", "createChart", + "speechToText", "detectLanguage", "neutralizeData", "executeCode", + "listWorkflowHistory", "readWorkflowMessages", + } + for _toolName in _CORE_ONLY_TOOLS: + _td = registry.getTool(_toolName) + if _td: + _td.toolSet = "core" diff --git a/modules/serviceCenter/services/serviceAgent/toolRegistry.py b/modules/serviceCenter/services/serviceAgent/toolRegistry.py index d241bb93..b4b5cd86 100644 --- a/modules/serviceCenter/services/serviceAgent/toolRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolRegistry.py @@ -125,20 +125,22 @@ class ToolRegistry: durationMs=durationMs ) - def formatToolsForPrompt(self) -> str: - """Format all tools as text for system prompt (text-based fallback).""" + def formatToolsForPrompt(self, toolSet: str = None) -> str: + """Format tools as text for system prompt (text-based fallback).""" + tools = self.getTools(toolSet=toolSet) if toolSet else list(self._tools.values()) parts = [] - for tool in self._tools.values(): + for tool in tools: paramStr = ", ".join( f"{k}: {v}" for k, v in tool.parameters.items() ) if tool.parameters else "none" parts.append(f"- **{tool.name}**: {tool.description}\n Parameters: {{{paramStr}}}") return "\n".join(parts) - def formatToolsForFunctionCalling(self) -> List[Dict[str, Any]]: - """Format all tools as OpenAI-compatible function definitions for native function calling.""" + def formatToolsForFunctionCalling(self, toolSet: str = None) -> List[Dict[str, Any]]: + """Format tools as OpenAI-compatible function definitions for native function calling.""" + tools = self.getTools(toolSet=toolSet) if toolSet else list(self._tools.values()) functions = [] - for tool in self._tools.values(): + for tool in tools: functions.append({ "type": "function", "function": {