fixed voice feat commcoach

This commit is contained in:
ValueOn AG 2026-04-01 21:59:28 +02:00
parent 5a40b54524
commit 0a5fa20cb8
12 changed files with 932 additions and 200 deletions

View file

@ -172,6 +172,7 @@ class AiCallRequest(BaseModel):
contentParts: Optional[List['ContentPart']] = None # Content parts for model-aware chunking contentParts: Optional[List['ContentPart']] = None # Content parts for model-aware chunking
messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="OpenAI-style messages for multi-turn agent conversations") messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="OpenAI-style messages for multi-turn agent conversations")
tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool definitions for native function calling") tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool definitions for native function calling")
toolChoice: Optional[Any] = Field(default=None, description="Tool choice: 'auto', 'none', or specific tool (passed through to model call)")
requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override: True=force, False=skip, None=use config") requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override: True=force, False=skip, None=use config")

View file

@ -228,6 +228,10 @@ class UpdateContextRequest(BaseModel):
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
content: str = Field(description="User message text") content: str = Field(description="User message text")
contentType: Optional[CoachingMessageContentType] = CoachingMessageContentType.TEXT contentType: Optional[CoachingMessageContentType] = CoachingMessageContentType.TEXT
fileIds: Optional[List[str]] = Field(default=None, description="Attached file IDs for agent context")
dataSourceIds: Optional[List[str]] = Field(default=None, description="Personal data source IDs")
featureDataSourceIds: Optional[List[str]] = Field(default=None, description="Feature data source IDs")
allowedProviders: Optional[List[str]] = Field(default=None, description="Allowed AI providers")
class CreateTaskRequest(BaseModel): class CreateTaskRequest(BaseModel):

View file

@ -334,9 +334,8 @@ async def startSession(
try: try:
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId) voiceInterface = getVoiceInterface(context.user, mandateId)
from .serviceCommcoach import _getUserVoicePrefs from .serviceCommcoach import _getUserVoicePrefs, _stripMarkdownForTts, _buildTtsConfigErrorMessage
language, voiceName = _getUserVoicePrefs(userId, mandateId) language, voiceName = _getUserVoicePrefs(userId, mandateId)
from .serviceCommcoach import _stripMarkdownForTts
ttsResult = await voiceInterface.textToSpeech( ttsResult = await voiceInterface.textToSpeech(
text=_stripMarkdownForTts(greetingText), text=_stripMarkdownForTts(greetingText),
languageCode=language, languageCode=language,
@ -349,8 +348,12 @@ async def startSession(
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode() audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
).decode() ).decode()
yield f"data: {json.dumps({'type': 'ttsAudio', 'data': {'audio': audioB64, 'format': 'mp3'}})}\n\n" yield f"data: {json.dumps({'type': 'ttsAudio', 'data': {'audio': audioB64, 'format': 'mp3'}})}\n\n"
else:
errorDetail = ttsResult.get("error", "Text-to-Speech failed")
yield f"data: {json.dumps({'type': 'error', 'data': {'message': _buildTtsConfigErrorMessage(language, voiceName, errorDetail), 'detail': errorDetail, 'ttsLanguage': language, 'ttsVoice': voiceName}})}\n\n"
except Exception as e: except Exception as e:
logger.warning(f"TTS failed for resumed session: {e}") logger.warning(f"TTS failed for resumed session: {e}")
yield f"data: {json.dumps({'type': 'error', 'data': {'message': 'Die konfigurierte Stimme für diese Sprache ist ungültig oder nicht verfügbar. Bitte passe sie unter Einstellungen > Stimme & Sprache an.', 'detail': str(e)}})}\n\n"
yield f"data: {json.dumps({'type': 'complete', 'data': {}, 'timestamp': getIsoTimestamp()})}\n\n" yield f"data: {json.dumps({'type': 'complete', 'data': {}, 'timestamp': getIsoTimestamp()})}\n\n"
return StreamingResponse( return StreamingResponse(
@ -511,7 +514,13 @@ async def sendMessageStream(
_activeProcessTasks.pop(sessionId, None) _activeProcessTasks.pop(sessionId, None)
task = asyncio.create_task( task = asyncio.create_task(
service.processMessage(sessionId, contextId, body.content, interface) service.processMessage(
sessionId, contextId, body.content, interface,
fileIds=body.fileIds,
dataSourceIds=body.dataSourceIds,
featureDataSourceIds=body.featureDataSourceIds,
allowedProviders=body.allowedProviders,
)
) )
task.add_done_callback(_onTaskDone) task.add_done_callback(_onTaskDone)
_activeProcessTasks[sessionId] = task _activeProcessTasks[sessionId] = task

View file

@ -6,6 +6,7 @@ Manages the coaching pipeline: message processing, AI calls, scoring, task extra
""" """
import re import re
import html
import logging import logging
import json import json
import asyncio import asyncio
@ -43,25 +44,117 @@ from .serviceCommcoachContextRetrieval import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _selectConfiguredVoice(
language: str,
voiceMap: Any,
legacyVoice: Optional[str] = None,
legacyLanguage: Optional[str] = None,
) -> Optional[str]:
"""Resolve the configured TTS voice for a language from ttsVoiceMap, then legacy ttsVoice."""
normalizedLanguage = str(language or "").strip()
normalizedLower = normalizedLanguage.lower()
baseLanguage = normalizedLower.split("-", 1)[0] if normalizedLower else ""
if isinstance(voiceMap, dict) and voiceMap:
direct = voiceMap.get(normalizedLanguage)
if isinstance(direct, str) and direct.strip():
return direct.strip()
directBase = voiceMap.get(baseLanguage)
if isinstance(directBase, str) and directBase.strip():
return directBase.strip()
for mapKey, mapValue in voiceMap.items():
if not isinstance(mapValue, str) or not mapValue.strip():
continue
keyNorm = str(mapKey or "").strip().lower()
if keyNorm == normalizedLower or keyNorm == baseLanguage or (baseLanguage and keyNorm.startswith(baseLanguage + "-")):
return mapValue.strip()
if legacyVoice and str(legacyVoice).strip():
legacyLangNorm = str(legacyLanguage or "").strip().lower()
if not legacyLangNorm or legacyLangNorm == normalizedLower:
return str(legacyVoice).strip()
return None
def _buildTtsConfigErrorMessage(language: str, voiceName: Optional[str], rawError: str = "") -> str:
if voiceName:
return (
f'Die konfigurierte Stimme "{voiceName}" für {language} ist ungültig oder nicht verfügbar. '
'Bitte passe sie unter Einstellungen > Stimme & Sprache an.'
)
return (
f'Für die Sprache {language} ist keine gültige TTS-Stimme konfiguriert. '
'Bitte prüfe die Einstellungen unter Stimme & Sprache.'
)
def _getUserVoicePrefs(userId: str, mandateId: Optional[str] = None) -> tuple: def _getUserVoicePrefs(userId: str, mandateId: Optional[str] = None) -> tuple:
"""Load voice language and voiceName from central UserVoicePreferences. """Load voice language and voiceName from central UserVoicePreferences.
Returns (language, voiceName) tuple.""" Returns (language, voiceName) tuple."""
try: try:
from modules.datamodels.datamodelUam import UserVoicePreferences from modules.datamodels.datamodelUam import UserVoicePreferences
from modules.security.rootAccess import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface() rootIf = getRootInterface()
prefs = rootIf.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
if not prefs and mandateId:
prefs = rootIf.db.getRecordset( prefs = rootIf.db.getRecordset(
UserVoicePreferences, UserVoicePreferences,
recordFilter={"userId": userId} recordFilter={"userId": userId}
) )
if prefs: if prefs:
p = prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump() allPrefs = [
return (p.get("ttsLanguage") or p.get("sttLanguage") or "de-DE", p.get("ttsVoice")) pref if isinstance(pref, dict) else pref.model_dump()
for pref in prefs
]
scopedPref = next(
(
pref for pref in allPrefs
if str(pref.get("mandateId") or "").strip() == str(mandateId or "").strip()
),
None,
)
globalPref = next(
(
pref for pref in allPrefs
if not str(pref.get("mandateId") or "").strip()
),
None,
)
language = (
(globalPref or {}).get("ttsLanguage")
or (globalPref or {}).get("sttLanguage")
or (scopedPref or {}).get("ttsLanguage")
or (scopedPref or {}).get("sttLanguage")
or "de-DE"
)
scopedVoiceFromMap = _selectConfiguredVoice(
language=language,
voiceMap=(scopedPref or {}).get("ttsVoiceMap"),
)
globalVoice = _selectConfiguredVoice(
language=language,
voiceMap=(globalPref or {}).get("ttsVoiceMap"),
legacyVoice=(globalPref or {}).get("ttsVoice"),
legacyLanguage=(globalPref or {}).get("ttsLanguage"),
)
scopedLegacyVoice = _selectConfiguredVoice(
language=language,
voiceMap=None,
legacyVoice=(scopedPref or {}).get("ttsVoice"),
legacyLanguage=(scopedPref or {}).get("ttsLanguage"),
)
anyPref = allPrefs[0]
fallbackVoice = _selectConfiguredVoice(
language=language,
voiceMap=(anyPref or {}).get("ttsVoiceMap"),
legacyVoice=(anyPref or {}).get("ttsVoice"),
legacyLanguage=(anyPref or {}).get("ttsLanguage"),
)
voiceName = scopedVoiceFromMap or globalVoice or scopedLegacyVoice or fallbackVoice
return (language, voiceName)
except Exception as e: except Exception as e:
logger.warning(f"Failed to load UserVoicePreferences for user={userId}: {e}") logger.warning(f"Failed to load UserVoicePreferences for user={userId}: {e}")
return ("de-DE", None) return ("de-DE", None)
@ -111,26 +204,91 @@ def cleanupSessionEvents(sessionId: str):
CHUNK_WORD_SIZE = 4 CHUNK_WORD_SIZE = 4
CHUNK_DELAY_SECONDS = 0.05 CHUNK_DELAY_SECONDS = 0.05
def _wrapEmailHtml(contentHtml: str) -> str:
"""Wrap AI-generated HTML content in a styled email shell.""" def _normalizeEmailBulletList(values: Any, maxItems: int = 4) -> List[str]:
return f"""<!DOCTYPE html> items: List[str] = []
<html lang="de"> if not isinstance(values, list):
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head> return items
<body style="margin:0;padding:0;background-color:#f4f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"> for value in values:
<div style="background-color:#f4f4f7;padding:32px 16px"> text = str(value or "").strip()
<div style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06)"> if text:
<div style="background:linear-gradient(135deg,#2563eb,#1e40af);padding:28px 32px"> items.append(text)
<h1 style="margin:0;color:#ffffff;font-size:20px;font-weight:600">Coaching-Session Zusammenfassung</h1> if len(items) >= maxItems:
<p style="margin:6px 0 0;color:rgba(255,255,255,0.8);font-size:13px">PowerOn CommCoach</p> break
</div> return items
<div style="padding:28px 32px;color:#374151;font-size:15px;line-height:1.65">{contentHtml}</div>
<div style="padding:18px 32px;background:#f9fafb;border-top:1px solid #e5e7eb;text-align:center">
<p style="margin:0;color:#9ca3af;font-size:12px">Diese Zusammenfassung wurde automatisch erstellt.</p> def _buildSummaryEmailBlock(
</div> emailData: Optional[Dict[str, Any]],
</div> summary: str,
</div> contextTitle: str,
</body> ) -> str:
</html>""" """Render a stable, mail-client-friendly CommCoach summary block."""
payload = emailData or {}
headline = str(payload.get("headline") or contextTitle or "Coaching-Session").strip()
intro = str(payload.get("intro") or "").strip()
coreTopic = str(payload.get("coreTopic") or "").strip()
insights = _normalizeEmailBulletList(payload.get("insights"))
nextSteps = _normalizeEmailBulletList(payload.get("nextSteps"))
progress = _normalizeEmailBulletList(payload.get("progress"))
if not (intro or coreTopic or insights or nextSteps or progress):
escapedSummary = html.escape(summary or "").replace("\n", "<br>")
return (
'<div style="border:1px solid #e5e7eb;border-radius:10px;padding:20px 22px;'
'background-color:#ffffff;">'
f'<h3 style="margin:0 0 12px 0;font-size:18px;line-height:1.3;color:#1f2937;">{html.escape(headline)}</h3>'
f'<div style="font-size:15px;line-height:1.7;color:#374151;">{escapedSummary}</div>'
'</div>'
)
def _renderSection(title: str, bodyHtml: str) -> str:
if not bodyHtml:
return ""
return (
'<tr><td style="padding:0 0 18px 0;">'
f'<div style="font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;'
f'color:#1d4ed8;margin:0 0 8px 0;">{html.escape(title)}</div>'
f'<div style="font-size:15px;line-height:1.7;color:#374151;">{bodyHtml}</div>'
'</td></tr>'
)
def _renderList(values: List[str]) -> str:
if not values:
return ""
rows = "".join(
'<tr>'
'<td valign="top" style="padding:0 10px 8px 0;font-size:15px;line-height:1.6;color:#2563eb;">•</td>'
f'<td style="padding:0 0 8px 0;font-size:15px;line-height:1.6;color:#374151;">{html.escape(item)}</td>'
'</tr>'
for item in values
)
return f'<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">{rows}</table>'
introHtml = f'<p style="margin:0;">{html.escape(intro)}</p>' if intro else ""
coreTopicHtml = f'<p style="margin:0;">{html.escape(coreTopic)}</p>' 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 (
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
'style="border-collapse:separate;border-spacing:0;background-color:#ffffff;'
'border:1px solid #e5e7eb;border-radius:12px;">'
'<tr><td style="padding:22px 22px 4px 22px;">'
f'<h3 style="margin:0 0 6px 0;font-size:20px;line-height:1.3;color:#111827;">{html.escape(headline)}</h3>'
f'<p style="margin:0 0 18px 0;font-size:13px;line-height:1.5;color:#6b7280;">Thema: {html.escape(contextTitle)}</p>'
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">'
f'{sectionsHtml}'
'</table>'
'</td></tr>'
'</table>'
)
DOC_INTENT_MAX_DOCS = 3 DOC_INTENT_MAX_DOCS = 3
DOC_CONTENT_MAX_CHARS = 3000 DOC_CONTENT_MAX_CHARS = 3000
@ -160,7 +318,7 @@ def _stripPendingUserMessages(messages: List[Dict[str, Any]]) -> List[Dict[str,
def _parseAiJsonResponse(rawText: str) -> Dict[str, Any]: def _parseAiJsonResponse(rawText: str) -> Dict[str, Any]:
"""Parse the structured JSON response from AI. Strips optional markdown code fences.""" """Parse optional structured AI output; otherwise treat free text as normal response."""
text = rawText.strip() text = rawText.strip()
if text.startswith("```"): if text.startswith("```"):
lines = text.split("\n") lines = text.split("\n")
@ -169,10 +327,14 @@ def _parseAiJsonResponse(rawText: str) -> Dict[str, Any]:
lines = lines[:-1] lines = lines[:-1]
text = "\n".join(lines) text = "\n".join(lines)
try: try:
return json.loads(text) parsed = json.loads(text)
if isinstance(parsed, dict):
if parsed.get("text") and not parsed.get("speech"):
parsed["speech"] = parsed.get("text")
return parsed
return {"text": rawText.strip(), "speech": rawText.strip(), "documents": []}
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning(f"AI JSON parse failed, using raw text: {text[:200]}") return {"text": rawText.strip(), "speech": rawText.strip(), "documents": []}
return {"text": rawText.strip(), "speech": "", "documents": []}
async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mandateId: str, async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mandateId: str,
@ -197,8 +359,20 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode() audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
).decode() ).decode()
await emitSessionEvent(sessionId, "ttsAudio", {"audio": audioB64, "format": "mp3"}) await emitSessionEvent(sessionId, "ttsAudio", {"audio": audioB64, "format": "mp3"})
return
errorDetail = ttsResult.get("error", "Text-to-Speech failed")
await emitSessionEvent(sessionId, "error", {
"message": _buildTtsConfigErrorMessage(language, voiceName, errorDetail),
"detail": errorDetail,
"ttsLanguage": language,
"ttsVoice": voiceName,
})
except Exception as e: except Exception as e:
logger.warning(f"TTS failed for session {sessionId}: {e}") logger.warning(f"TTS failed for session {sessionId}: {e}")
await emitSessionEvent(sessionId, "error", {
"message": _buildTtsConfigErrorMessage("de-DE", None, str(e)),
"detail": str(e),
})
def _resolveFileNameAndMime(title: str) -> tuple: def _resolveFileNameAndMime(title: str) -> tuple:
@ -400,6 +574,151 @@ def _getDocumentSummaries(contextId: str, userId: str, interface,
return None return None
def _createCommcoachRagFn(
userId: str,
featureInstanceId: str,
mandateId: str,
context: Dict[str, Any],
tasks: List[Dict[str, Any]],
currentUser=None,
):
"""Create a CommCoach-specific RAG function combining KnowledgeService RAG with live coaching DB context."""
async def _buildRagContext(
currentPrompt: str, workflowId: str, userId: str,
featureInstanceId: str, mandateId: str, **kwargs
) -> str:
parts = []
# 1. Standard KnowledgeService RAG (finds indexed session chunks + files)
try:
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
serviceContext = ServiceCenterContext(
user=currentUser,
mandate_id=mandateId,
feature_instance_id=featureInstanceId,
)
knowledgeService = getService("knowledge", serviceContext)
ragContext = await knowledgeService.buildAgentContext(
currentPrompt=currentPrompt,
workflowId=workflowId,
userId=userId,
featureInstanceId=featureInstanceId,
mandateId=mandateId,
)
if ragContext:
parts.append(ragContext)
except Exception as e:
logger.debug(f"CommCoach RAG knowledge context failed: {e}")
# 2. Live coaching DB context (current goals, tasks, rolling overview)
liveContext = []
goals = _parseJsonField(context.get("goals")) if context else None
if goals:
goalTexts = [g.get("text", g) if isinstance(g, dict) else str(g) for g in goals if g]
if goalTexts:
liveContext.append("Aktuelle Ziele:\n" + "\n".join(f"- {g}" for g in goalTexts))
openTasks = [t for t in (tasks or []) if t.get("status") in ("open", "inProgress")]
if openTasks:
taskLines = [f"- {t.get('title', '')}" for t in openTasks[:5]]
liveContext.append("Offene Aufgaben:\n" + "\n".join(taskLines))
rollingOverview = context.get("rollingOverview") if context else None
if rollingOverview:
liveContext.append(f"Gesamtüberblick bisheriger Sessions:\n{rollingOverview[:500]}")
insights = _parseJsonField(context.get("insights")) if context else None
if insights:
insightTexts = [i.get("text", i) if isinstance(i, dict) else str(i) for i in insights[-5:] if i]
if insightTexts:
liveContext.append("Bisherige Erkenntnisse:\n" + "\n".join(f"- {t}" for t in insightTexts))
if liveContext:
parts.append("--- Coaching-Kontext (Live) ---\n" + "\n\n".join(liveContext))
return "\n\n".join(parts) if parts else ""
return _buildRagContext
def _parseJsonField(value, fallback=None):
if not value:
return fallback
if isinstance(value, (list, dict)):
return value
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return fallback
_RESEARCH_KEYWORDS = re.compile(
r"\b(such|recherchier|schau nach|im web|finde heraus|google|online|nachschlagen|"
r"search|look up|find out|browse)\b",
re.IGNORECASE,
)
def _shouldActivateTools(
fileIds: Optional[List[str]],
dataSourceIds: Optional[List[str]],
featureDataSourceIds: Optional[List[str]],
userMessage: str,
) -> bool:
"""Decide whether the agent should have tools activated for this turn."""
if fileIds:
return True
if dataSourceIds:
return True
if featureDataSourceIds:
return True
if _RESEARCH_KEYWORDS.search(userMessage or ""):
return True
return False
def _buildConversationHistory(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Convert coaching messages to OpenAI-style conversation history for the agent."""
history = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role in ("user", "assistant") and content:
history.append({"role": role, "content": content})
return history
_TTS_WORD_LIMIT = 200
async def _prepareSpeechText(fullText: str, callAiFn) -> str:
"""Prepare text for TTS. Short responses used directly; long ones get summarized."""
cleaned = _stripMarkdownForTts(fullText)
wordCount = len(cleaned.split())
if wordCount <= _TTS_WORD_LIMIT:
return cleaned
try:
prompt = f"""Fasse den folgenden Text in 3-4 natürlichen, gesprochenen Sätzen zusammen.
Der Text soll vorgelesen werden schreibe daher natürlich und flüssig, keine Aufzählungen.
Behalte die wichtigsten Punkte und den Ton bei.
Text:
{cleaned[:3000]}
Antworte NUR mit der gekürzten Sprachversion."""
response = await callAiFn(
"Du kürzt Texte für Sprachausgabe. Antworte kurz und natürlich.",
prompt,
)
if response and response.errorCount == 0 and response.content:
return response.content.strip()
except Exception as e:
logger.warning(f"Speech summary generation failed: {e}")
return cleaned[:1500]
class CommcoachService: class CommcoachService:
"""Coaching orchestrator: processes messages, calls AI, extracts tasks and scores.""" """Coaching orchestrator: processes messages, calls AI, extracts tasks and scores."""
@ -409,14 +728,20 @@ class CommcoachService:
self.instanceId = instanceId self.instanceId = instanceId
self.userId = str(currentUser.id) self.userId = str(currentUser.id)
async def processMessage(self, sessionId: str, contextId: str, userContent: str, interface) -> Dict[str, Any]: async def processMessage(
self, sessionId: str, contextId: str, userContent: str, interface,
fileIds: Optional[List[str]] = None,
dataSourceIds: Optional[List[str]] = None,
featureDataSourceIds: Optional[List[str]] = None,
allowedProviders: Optional[List[str]] = None,
) -> Dict[str, Any]:
""" """
Process a user message through the coaching pipeline: Process a user message through the agent-based coaching pipeline:
1. Store user message 1. Store user message
2. Build context with history 2. Build coaching system prompt + session history
3. Call AI for coaching response 3. Run AgentService with CommCoach RAG and optional tools
4. Store assistant message 4. Map agent events to CommCoach SSE events
5. Emit SSE events 5. Post-processing: store message, TTS, tasks, scores
""" """
from . import interfaceFeatureCommcoach as interfaceDb from . import interfaceFeatureCommcoach as interfaceDb
@ -474,88 +799,62 @@ class CommcoachService:
logger.warning(f"History compression failed for session {sessionId}: {e}") logger.warning(f"History compression failed for session {sessionId}: {e}")
previousMessages = messages[-20:] previousMessages = messages[-20:]
# Combine all pending user messages (after last assistant message) as the user prompt
combinedUserPrompt = _buildCombinedUserPrompt(previousMessages) combinedUserPrompt = _buildCombinedUserPrompt(previousMessages)
if not combinedUserPrompt: if not combinedUserPrompt:
combinedUserPrompt = userContent combinedUserPrompt = userContent
# Strip pending user messages from previousMessages to avoid redundancy in system prompt
contextMessages = _stripPendingUserMessages(previousMessages) contextMessages = _stripPendingUserMessages(previousMessages)
tasks = interface.getTasks(contextId, self.userId) tasks = interface.getTasks(contextId, self.userId)
await emitSessionEvent(sessionId, "status", {"label": "Kontext wird geladen..."}) await emitSessionEvent(sessionId, "status", {"label": "Kontext wird geladen..."})
retrievalResult = await self._buildRetrievalContext(
contextId, sessionId, combinedUserPrompt, context, interface
)
persona = _resolvePersona(session, interface) persona = _resolvePersona(session, interface)
documentSummaries = _getDocumentSummaries(
contextId, self.userId, interface, mandateId=self.mandateId, instanceId=self.instanceId
)
# Document intent detection (pre-AI-call)
referencedDocumentContents = None
allDocs = _getPlatformFileList(self.mandateId, self.instanceId) if documentSummaries else []
if allDocs:
await emitSessionEvent(sessionId, "status", {"label": "Dokumente werden geprueft..."})
docIntent = await _resolveDocumentIntent(combinedUserPrompt, allDocs, self._callAi)
if not docIntent.get("noDocumentAction"):
docIdsToLoad = list(set((docIntent.get("read") or []) + (docIntent.get("update") or [])))
if docIdsToLoad:
referencedDocumentContents = _loadDocumentContents(
docIdsToLoad, interface, mandateId=self.mandateId, instanceId=self.instanceId
)
systemPrompt = aiPrompts.buildCoachingSystemPrompt( systemPrompt = aiPrompts.buildCoachingSystemPrompt(
context, context,
contextMessages, contextMessages,
tasks, tasks,
previousSessionSummaries=retrievalResult.get("previousSessionSummaries"),
earlierSummary=earlierSummary, earlierSummary=earlierSummary,
rollingOverview=retrievalResult.get("rollingOverview"),
retrievedSession=retrievalResult.get("retrievedSession"),
retrievedByTopic=retrievalResult.get("retrievedByTopic"),
persona=persona, persona=persona,
documentSummaries=documentSummaries,
referencedDocumentContents=referencedDocumentContents,
) )
if retrievalResult.get("intent") == RetrievalIntent.SUMMARIZE_ALL: # Build conversation history for the agent
systemPrompt += "\n\nWICHTIG: Der Benutzer möchte eine Gesamtzusammenfassung. Erstelle eine umfassende Zusammenfassung aller genannten Sessions und der aktuellen Session." conversationHistory = _buildConversationHistory(contextMessages)
# Dynamic tool activation
useTools = _shouldActivateTools(fileIds, dataSourceIds, featureDataSourceIds, combinedUserPrompt)
# Call AI
await emitSessionEvent(sessionId, "status", {"label": "Coach formuliert Antwort..."}) await emitSessionEvent(sessionId, "status", {"label": "Coach formuliert Antwort..."})
try: try:
aiResponse = await self._callAi(systemPrompt, combinedUserPrompt) agentResponse = await self._runAgent(
sessionId=sessionId,
prompt=combinedUserPrompt,
systemPrompt=systemPrompt,
conversationHistory=conversationHistory,
context=context,
tasks=tasks,
fileIds=fileIds,
useTools=useTools,
allowedProviders=allowedProviders,
)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info(f"processMessage cancelled for session {sessionId} (new message arrived)") logger.info(f"processMessage cancelled for session {sessionId} (new message arrived)")
return createdUserMsg return createdUserMsg
except Exception as e: except Exception as e:
logger.error(f"AI call failed for session {sessionId}: {e}") logger.error(f"Agent call failed for session {sessionId}: {e}")
await emitSessionEvent(sessionId, "error", {"message": f"AI error: {str(e)}"}) await emitSessionEvent(sessionId, "error", {"message": f"AI error: {str(e)}"})
return createdUserMsg return createdUserMsg
responseRaw = aiResponse.content.strip() if aiResponse and aiResponse.errorCount == 0 else "" textContent = agentResponse or ""
if not responseRaw: if not textContent:
parsed = {"text": "Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es erneut.", "speech": "", "documents": []} textContent = "Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es erneut."
else:
parsed = _parseAiJsonResponse(responseRaw)
textContent = parsed.get("text", "")
speechContent = parsed.get("speech", "")
documents = parsed.get("documents", [])
if asyncio.current_task() and asyncio.current_task().cancelled(): if asyncio.current_task() and asyncio.current_task().cancelled():
logger.info(f"processMessage cancelled before storing response for session {sessionId}") logger.info(f"processMessage cancelled before storing response for session {sessionId}")
return createdUserMsg return createdUserMsg
for doc in documents:
await _saveOrUpdateDocument(doc, contextId, self.userId, self.mandateId, self.instanceId, interface, sessionId, user=self.currentUser)
assistantMsg = CoachingMessage( assistantMsg = CoachingMessage(
sessionId=sessionId, sessionId=sessionId,
contextId=contextId, contextId=contextId,
@ -571,8 +870,11 @@ class CommcoachService:
await emitSessionEvent(sessionId, "status", {"label": "Antwort wird verarbeitet..."}) await emitSessionEvent(sessionId, "status", {"label": "Antwort wird verarbeitet..."})
# TTS: use free-text directly; for long responses, generate speech summary
speechText = await _prepareSpeechText(textContent, self._callAi)
ttsTask = asyncio.create_task( ttsTask = asyncio.create_task(
_generateAndEmitTts(sessionId, speechContent, self.currentUser, self.mandateId, self.instanceId, interface) _generateAndEmitTts(sessionId, speechText, self.currentUser, self.mandateId, self.instanceId, interface)
) )
await _emitChunkedResponse(sessionId, createdAssistantMsg, textContent) await _emitChunkedResponse(sessionId, createdAssistantMsg, textContent)
await ttsTask await ttsTask
@ -580,6 +882,75 @@ class CommcoachService:
await emitSessionEvent(sessionId, "complete", {}) await emitSessionEvent(sessionId, "complete", {})
return createdAssistantMsg return createdAssistantMsg
async def _runAgent(
self,
sessionId: str,
prompt: str,
systemPrompt: str,
conversationHistory: List[Dict[str, Any]],
context: Dict[str, Any],
tasks: List[Dict[str, Any]],
fileIds: Optional[List[str]] = None,
useTools: bool = False,
allowedProviders: Optional[List[str]] = None,
) -> str:
"""Run the AgentService for a coaching message. Returns the final text response."""
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentConfig, AgentEventTypeEnum
serviceContext = ServiceCenterContext(
user=self.currentUser,
mandate_id=self.mandateId,
feature_instance_id=self.instanceId,
)
agentService = getService("agent", serviceContext)
config = AgentConfig(
toolSet="commcoach" if useTools else "none",
maxRounds=3 if useTools else 1,
temperature=0.4,
)
buildRagContextFn = _createCommcoachRagFn(
userId=self.userId,
featureInstanceId=self.instanceId,
mandateId=self.mandateId,
context=context,
tasks=tasks,
currentUser=self.currentUser,
)
finalText = ""
async for event in agentService.runAgent(
prompt=prompt,
fileIds=fileIds,
config=config,
toolSet=config.toolSet,
workflowId=f"commcoach:{sessionId}",
conversationHistory=conversationHistory,
buildRagContextFn=buildRagContextFn,
systemPromptOverride=systemPrompt,
):
if event.type == AgentEventTypeEnum.CHUNK:
chunk = event.content or ""
finalText += chunk
elif event.type == AgentEventTypeEnum.MESSAGE:
finalText += event.content or ""
elif event.type == AgentEventTypeEnum.FINAL:
if not finalText:
finalText = event.content or ""
elif event.type == AgentEventTypeEnum.TOOL_CALL:
await emitSessionEvent(sessionId, "toolCall", event.data or {})
elif event.type == AgentEventTypeEnum.TOOL_RESULT:
await emitSessionEvent(sessionId, "toolResult", event.data or {})
elif event.type == AgentEventTypeEnum.AGENT_PROGRESS:
await emitSessionEvent(sessionId, "agentProgress", event.data or {})
elif event.type == AgentEventTypeEnum.ERROR:
await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent error"})
return finalText.strip()
async def processSessionOpening(self, sessionId: str, contextId: str, interface) -> Dict[str, Any]: async def processSessionOpening(self, sessionId: str, contextId: str, interface) -> Dict[str, Any]:
""" """
Generate and stream the opening greeting for a new session. Generate and stream the opening greeting for a new session.
@ -742,9 +1113,9 @@ class CommcoachService:
}) })
return session return session
# Generate summary (AI returns JSON with summary + emailHtml) # Generate summary (AI returns JSON with summary + structured email payload)
summary = None summary = None
emailHtml = None emailData = None
try: try:
summaryPrompt = aiPrompts.buildSummaryPrompt(messages, context.get("title", "Coaching")) summaryPrompt = aiPrompts.buildSummaryPrompt(messages, context.get("title", "Coaching"))
summaryResponse = await self._callAi("Du bist ein präziser Zusammenfasser. Antworte NUR als JSON.", summaryPrompt) summaryResponse = await self._callAi("Du bist ein präziser Zusammenfasser. Antworte NUR als JSON.", summaryPrompt)
@ -752,7 +1123,10 @@ class CommcoachService:
parsed = aiPrompts.parseJsonResponse(summaryResponse.content.strip(), None) parsed = aiPrompts.parseJsonResponse(summaryResponse.content.strip(), None)
if isinstance(parsed, dict): if isinstance(parsed, dict):
summary = parsed.get("summary") or parsed.get("text") summary = parsed.get("summary") or parsed.get("text")
emailHtml = parsed.get("emailHtml") if isinstance(parsed.get("email"), dict):
emailData = parsed.get("email")
elif isinstance(parsed.get("emailData"), dict):
emailData = parsed.get("emailData")
else: else:
summary = summaryResponse.content.strip() summary = summaryResponse.content.strip()
except Exception as e: except Exception as e:
@ -843,6 +1217,40 @@ class CommcoachService:
except Exception as e: except Exception as e:
logger.warning(f"Insight generation failed: {e}") logger.warning(f"Insight generation failed: {e}")
# Index session data for RAG-based long-term memory
try:
from .serviceCommcoachIndexer import indexSessionData
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
serviceContext = ServiceCenterContext(
user=self.currentUser,
mandate_id=self.mandateId,
feature_instance_id=self.instanceId,
)
knowledgeService = getService("knowledge", serviceContext)
parsedGoals = aiPrompts._parseJsonField(context.get("goals") if context else None, [])
parsedInsights = aiPrompts._parseJsonField(context.get("insights") if context else None, [])
allTasks = interface.getTasks(contextId, self.userId)
await indexSessionData(
sessionId=sessionId,
contextId=contextId,
userId=self.userId,
featureInstanceId=self.instanceId,
mandateId=self.mandateId,
messages=messages,
summary=summary,
keyTopics=keyTopics,
goals=parsedGoals,
insights=parsedInsights,
tasks=allTasks,
contextTitle=context.get("title", "Coaching") if context else "Coaching",
knowledgeService=knowledgeService,
)
except Exception as e:
logger.warning(f"Coaching session indexing failed (non-blocking): {e}")
# Calculate duration # Calculate duration
startedAt = session.get("startedAt", "") startedAt = session.get("startedAt", "")
durationSeconds = 0 durationSeconds = 0
@ -898,7 +1306,7 @@ class CommcoachService:
# Send email summary # Send email summary
if summary: if summary:
contextTitle = context.get("title", "Coaching") if context else "Coaching" contextTitle = context.get("title", "Coaching") if context else "Coaching"
await self._sendSessionEmail(session, summary, emailHtml, contextTitle, interface) await self._sendSessionEmail(session, summary, emailData, contextTitle, interface)
await emitSessionEvent(sessionId, "sessionState", { await emitSessionEvent(sessionId, "sessionState", {
"status": "completed", "status": "completed",
@ -949,8 +1357,15 @@ class CommcoachService:
except Exception as e: except Exception as e:
logger.warning(f"Failed to update streak: {e}") logger.warning(f"Failed to update streak: {e}")
async def _sendSessionEmail(self, session: Dict[str, Any], summary: str, emailHtml: str, contextTitle: str, interface): async def _sendSessionEmail(
"""Send session summary via email if enabled. Uses AI-generated HTML directly.""" self,
session: Dict[str, Any],
summary: str,
emailData: Optional[Dict[str, Any]],
contextTitle: str,
interface,
):
"""Send session summary via email with the standard PowerOn layout."""
try: try:
profile = interface.getProfile(self.userId, self.instanceId) profile = interface.getProfile(self.userId, self.instanceId)
if profile and not profile.get("emailSummaryEnabled", True): if profile and not profile.get("emailSummaryEnabled", True):
@ -958,6 +1373,7 @@ class CommcoachService:
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.notifyMandateAdmins import _renderHtmlEmail, _resolveMandateName
rootInterface = getRootInterface() rootInterface = getRootInterface()
user = rootInterface.getUser(self.userId) user = rootInterface.getUser(self.userId)
@ -966,9 +1382,18 @@ class CommcoachService:
messaging = getMessagingInterface() messaging = getMessagingInterface()
subject = f"Coaching-Session Zusammenfassung: {contextTitle}" subject = f"Coaching-Session Zusammenfassung: {contextTitle}"
mandateName = _resolveMandateName(self.mandateId)
contentHtml = emailHtml if emailHtml else f"<p>{summary}</p>" contentHtml = _buildSummaryEmailBlock(emailData, summary, contextTitle)
htmlMessage = _wrapEmailHtml(contentHtml) 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) messaging.send("email", user.email, subject, htmlMessage)
interface.updateSession(session.get("id"), {"emailSent": True}) interface.updateSession(session.get("id"), {"emailSent": True})

View file

@ -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. - 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. - 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". - 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: Antwortformat:
Du antwortest IMMER als reines JSON-Objekt mit exakt diesen Feldern: - Antworte direkt als Freitext (KEIN JSON). Markdown-Formatierung ist erlaubt.
{"text": "...", "speech": "...", "documents": []} - 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. Tool-Nutzung:
"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. - Du hast Zugriff auf Tools (Dateien lesen, Web-Suche, Datenquellen abfragen) wenn der Benutzer Dateien/Quellen angehängt hat oder Recherche benötigt.
"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 []. - 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."""
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."""
if contextDescription: if contextDescription:
prompt += f"\n\nKontext-Beschreibung: {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: 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 = "" conversation = ""
for msg in messages: for msg in messages:
role = "Benutzer" if msg.get("role") == "user" else "Coach" 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}". 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.", "summary": "Kompakte Plaintext-Zusammenfassung fuer die App. Struktur: Kernthema, Erkenntnisse, Naechste Schritte, Fortschritt.",
"emailHtml": "<div>...</div>" "email": {{
"headline": "Kurze, professionelle Titelzeile fuer die E-Mail",
"intro": "1-2 Saetze, die den Kern der Session auf den Punkt bringen",
"coreTopic": "Das zentrale Thema in einem praezisen Satz",
"insights": ["Erkenntnis 1", "Erkenntnis 2"],
"nextSteps": ["Naechster Schritt 1", "Naechster Schritt 2"],
"progress": ["Fortschritt 1", "Fortschritt 2"]
}}
}} }}
Fuer "emailHtml": Erstelle ein professionell formatiertes HTML-Fragment (KEIN vollstaendiges HTML-Dokument, nur der Inhalt-Block). Regeln:
Verwende inline CSS fuer schoene Darstellung in E-Mail-Clients: - KEIN HTML erzeugen.
- Verwende <h3> fuer Abschnitte (color: #1e40af; margin: 20px 0 8px; font-size: 16px) - "summary" ist reiner Plaintext ohne Markdown.
- Verwende <ul>/<li> fuer Stichpunkte (margin: 4px 0; line-height: 1.6) - "headline" kurz und professionell.
- Verwende <strong> fuer Hervorhebungen - "intro" in natuerlichem Business-Deutsch.
- Verwende <p> fuer Fliesstext (color: #374151; line-height: 1.65; font-size: 15px) - "insights", "nextSteps" und "progress" jeweils als kurze Stichpunkte.
- Verwende <hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0"> als Trenner - Maximal 4 Eintraege pro Liste.
- Wenn eine Liste leer ist, gib [] zurueck.
Fuer "summary": Kompakter Plaintext ohne HTML/Markdown. Abschnitte mit Zeilenumbruechen trennen.
Gespräch: Gespräch:
{conversation} {conversation}
Antworte auf Deutsch, sachlich und kompakt. NUR JSON, keine Erklaerungen.""" Antworte auf Deutsch, sachlich, klar und kompakt. NUR JSON, keine Erklaerungen."""
def buildScoringPrompt(messages: List[Dict[str, Any]], contextCategory: str) -> str: def buildScoringPrompt(messages: List[Dict[str, Any]], contextCategory: str) -> str:

View file

@ -0,0 +1,223 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
CommCoach Session Indexer.
Indexes coaching session data into the knowledge store (pgvector) for RAG-based long-term memory.
Called after session completion to ensure semantic searchability across 20+ sessions.
"""
import logging
import uuid
import json
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
_COACHING_FILE_PREFIX = "coaching-session:"
async def indexSessionData(
sessionId: str,
contextId: str,
userId: str,
featureInstanceId: str,
mandateId: str,
messages: List[Dict[str, Any]],
summary: Optional[str],
keyTopics: Optional[str],
goals: Optional[List[Any]],
insights: Optional[List[Any]],
tasks: Optional[List[Dict[str, Any]]],
contextTitle: str = "",
knowledgeService=None,
):
"""Index a completed coaching session into the knowledge store.
Creates ContentChunks with embeddings for:
- Each User+Assistant message pair (maximum detail depth)
- Session summary
- Key topics (individually, for precise retrieval)
- Current goals
- New insights
- Tasks (open + done)
"""
if not knowledgeService:
logger.warning("No knowledge service available for coaching indexer")
return
syntheticFileId = f"{_COACHING_FILE_PREFIX}{sessionId}"
chunks = []
# 1. Message pairs (User + Assistant) as individual chunks
messagePairs = _extractMessagePairs(messages)
for idx, pair in enumerate(messagePairs):
chunks.append({
"contentObjectId": f"{sessionId}:msg-pair:{idx}",
"data": pair["text"],
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": f"message-pair-{idx}",
"type": "coaching-message-pair",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 2. Session summary
if summary:
chunks.append({
"contentObjectId": f"{sessionId}:summary",
"data": f"Session-Zusammenfassung ({contextTitle}): {summary}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": "summary",
"type": "coaching-session-summary",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 3. Key topics (each as separate chunk for precise retrieval)
parsedTopics = _parseJsonSafe(keyTopics, [])
for tidx, topic in enumerate(parsedTopics):
topicStr = str(topic).strip()
if topicStr:
chunks.append({
"contentObjectId": f"{sessionId}:topic:{tidx}",
"data": f"Coaching-Thema ({contextTitle}): {topicStr}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": f"topic-{tidx}",
"type": "coaching-key-topic",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 4. Goals
if goals:
goalTexts = [g.get("text", g) if isinstance(g, dict) else str(g) for g in goals if g]
if goalTexts:
goalsStr = "\n".join(f"- {g}" for g in goalTexts)
chunks.append({
"contentObjectId": f"{sessionId}:goals",
"data": f"Coaching-Ziele ({contextTitle}):\n{goalsStr}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": "goals",
"type": "coaching-goals",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 5. Insights
if insights:
insightTexts = [i.get("text", i) if isinstance(i, dict) else str(i) for i in insights if i]
if insightTexts:
insightsStr = "\n".join(f"- {t}" for t in insightTexts)
chunks.append({
"contentObjectId": f"{sessionId}:insights",
"data": f"Coaching-Erkenntnisse ({contextTitle}):\n{insightsStr}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": "insights",
"type": "coaching-insights",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 6. Tasks
if tasks:
taskLines = []
for t in tasks:
status = t.get("status", "open")
title = t.get("title", "")
if title:
taskLines.append(f"- [{status}] {title}")
if taskLines:
tasksStr = "\n".join(taskLines)
chunks.append({
"contentObjectId": f"{sessionId}:tasks",
"data": f"Coaching-Aufgaben ({contextTitle}):\n{tasksStr}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": "tasks",
"type": "coaching-tasks",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
if not chunks:
logger.info(f"No chunks to index for session {sessionId}")
return
logger.info(f"Indexing {len(chunks)} chunks for coaching session {sessionId}")
try:
contentObjects = [
{
"contentObjectId": c["contentObjectId"],
"contentType": "text",
"data": c["data"],
"contextRef": c["contextRef"],
}
for c in chunks
]
await knowledgeService.indexFile(
fileId=syntheticFileId,
fileName=f"coaching-session-{sessionId[:8]}",
mimeType="application/x-coaching-session",
userId=userId,
featureInstanceId=featureInstanceId,
mandateId=mandateId,
contentObjects=contentObjects,
)
logger.info(f"Successfully indexed coaching session {sessionId} ({len(chunks)} chunks)")
except Exception as e:
logger.error(f"Failed to index coaching session {sessionId}: {e}", exc_info=True)
def _extractMessagePairs(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
"""Extract User+Assistant pairs from message list."""
pairs = []
i = 0
while i < len(messages):
msg = messages[i]
if msg.get("role") == "user":
userText = (msg.get("content") or "").strip()
assistantText = ""
if i + 1 < len(messages) and messages[i + 1].get("role") == "assistant":
assistantText = (messages[i + 1].get("content") or "").strip()
i += 2
else:
i += 1
if userText:
text = f"Benutzer: {userText}"
if assistantText:
text += f"\nCoach: {assistantText}"
pairs.append({"text": text})
else:
i += 1
return pairs
def _parseJsonSafe(value, fallback):
if not value:
return fallback
if isinstance(value, (list, dict)):
return value
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return fallback

View file

@ -6,11 +6,44 @@ Handles daily reminders and scheduled email summaries.
""" """
import logging import logging
import html
from typing import Dict, Any, List from typing import Dict, Any, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _buildReminderHtmlBlock(contextTitles: List[str], streakDays: int) -> str:
rows = "".join(
'<tr>'
'<td valign="top" style="padding:0 10px 8px 0;font-size:15px;line-height:1.6;color:#2563eb;">•</td>'
f'<td style="padding:0 0 8px 0;font-size:15px;line-height:1.6;color:#374151;">{html.escape(title)}</td>'
'</tr>'
for title in contextTitles[:3]
)
topicsBlock = (
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
'style="border-collapse:separate;border-spacing:0;border:1px solid #e5e7eb;border-radius:12px;'
'background-color:#ffffff;margin:0 0 16px 0;">'
'<tr><td style="padding:18px 20px;">'
'<div style="font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;'
'color:#1d4ed8;margin:0 0 8px 0;">Aktive Coaching-Themen</div>'
f'<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">{rows}</table>'
'</td></tr></table>'
)
streakBlock = (
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
'style="border-collapse:separate;border-spacing:0;border:1px solid #dbeafe;border-radius:12px;'
'background:linear-gradient(135deg,#eff6ff,#f8fbff);">'
'<tr><td style="padding:18px 20px;">'
'<div style="font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;'
'color:#1d4ed8;margin:0 0 8px 0;">Dein Rhythmus</div>'
f'<div style="font-size:15px;line-height:1.7;color:#374151;">Aktueller Streak: '
f'<strong>{int(streakDays or 0)} Tage</strong></div>'
'</td></tr></table>'
)
return topicsBlock + streakBlock
def registerScheduledJobs(eventManagement): def registerScheduledJobs(eventManagement):
"""Register CommCoach scheduled jobs with the event management system.""" """Register CommCoach scheduled jobs with the event management system."""
try: try:
@ -31,6 +64,7 @@ async def _runDailyReminders():
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.shared.notifyMandateAdmins import _renderHtmlEmail, _resolveMandateName
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
db = DatabaseConnector( db = DatabaseConnector(
@ -71,15 +105,21 @@ async def _runDailyReminders():
contextTitles = [c.get("title", "Unbenannt") for c in contexts[:3]] contextTitles = [c.get("title", "Unbenannt") for c in contexts[:3]]
contextList = ", ".join(contextTitles) contextList = ", ".join(contextTitles)
subject = "Dein taegliches Coaching wartet" subject = "Dein tägliches Coaching wartet"
message = f""" mandateName = _resolveMandateName(profile.get("mandateId"))
<h2>Zeit fuer dein Coaching</h2> htmlMessage = _renderHtmlEmail(
<p>Du hast aktive Coaching-Themen: <strong>{contextList}</strong></p> "Zeit für dein tägliches Coaching",
<p>Nimm dir 10 Minuten fuer eine kurze Session. Konsistenz ist der Schluessel zu Fortschritt.</p> [
<p>Dein aktueller Streak: <strong>{profile.get('streakDays', 0)} Tage</strong></p> 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 sentCount += 1
except Exception as e: except Exception as e:
logger.warning(f"Failed to send reminder to user {profile.get('userId')}: {e}") logger.warning(f"Failed to send reminder to user {profile.get('userId')}: {e}")

View file

@ -134,7 +134,7 @@ class AiObjects:
logger.info(f"Attempting AI call with model: {model.name} (attempt {attempt + 1}/{len(failoverModelList)})") logger.info(f"Attempting AI call with model: {model.name} (attempt {attempt + 1}/{len(failoverModelList)})")
if request.messages: 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: else:
response = await self._callWithModel(model, prompt, context, options) response = await self._callWithModel(model, prompt, context, options)
@ -149,7 +149,7 @@ class AiObjects:
await asyncio.sleep(retryAfter + 0.5) await asyncio.sleep(retryAfter + 0.5)
try: try:
if request.messages: 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: else:
response = await self._callWithModel(model, prompt, context, options) response = await self._callWithModel(model, prompt, context, options)
logger.info(f"AI call successful with {model.name} after rate-limit retry") 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]], async def _callWithMessages(self, model: AiModel, messages: List[Dict[str, Any]],
options: AiCallOptions = None, 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.""" """Call a model with pre-built messages (agent mode). Supports tools for native function calling."""
import json as _json import json as _json
@ -302,7 +303,8 @@ class AiObjects:
messages=messages, messages=messages,
model=model, model=model,
options=options or {}, options=options or {},
tools=tools tools=tools,
toolChoice=toolChoice,
) )
modelResponse = await model.functionCall(modelCall) modelResponse = await model.functionCall(modelCall)
@ -379,7 +381,7 @@ class AiObjects:
for attempt, model in enumerate(failoverModelList): for attempt, model in enumerate(failoverModelList):
try: try:
logger.info(f"Streaming AI call with model: {model.name} (attempt {attempt + 1})") 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 yield chunk
return return
@ -390,7 +392,7 @@ class AiObjects:
logger.info(f"Rate limit on {model.name}, waiting {retryAfter:.1f}s before retry") logger.info(f"Rate limit on {model.name}, waiting {retryAfter:.1f}s before retry")
await asyncio.sleep(retryAfter + 0.5) await asyncio.sleep(retryAfter + 0.5)
try: 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 yield chunk
return return
except Exception as retryErr: except Exception as retryErr:
@ -421,6 +423,7 @@ class AiObjects:
async def _callWithMessagesStream( async def _callWithMessagesStream(
self, model: AiModel, messages: List[Dict[str, Any]], self, model: AiModel, messages: List[Dict[str, Any]],
options: AiCallOptions = None, tools: List[Dict[str, Any]] = None, options: AiCallOptions = None, tools: List[Dict[str, Any]] = None,
toolChoice: Any = None,
) -> AsyncGenerator[Union[str, AiCallResponse], None]: ) -> AsyncGenerator[Union[str, AiCallResponse], None]:
"""Stream a model call. Yields str deltas, then final AiCallResponse with billing.""" """Stream a model call. Yields str deltas, then final AiCallResponse with billing."""
from modules.datamodels.datamodelAi import AiModelCall, AiModelResponse from modules.datamodels.datamodelAi import AiModelCall, AiModelResponse
@ -429,7 +432,7 @@ class AiObjects:
startTime = time.time() startTime = time.time()
if not model.functionCallStream: 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: if response.content:
yield response.content yield response.content
yield response yield response
@ -438,6 +441,7 @@ class AiObjects:
modelCall = AiModelCall( modelCall = AiModelCall(
messages=messages, model=model, messages=messages, model=model,
options=options or {}, tools=tools, options=options or {}, tools=tools,
toolChoice=toolChoice,
) )
finalModelResponse = None finalModelResponse = None

View file

@ -444,7 +444,7 @@ async def health_check(currentUser: User = Depends(getCurrentUser)):
async def get_voice_settings(currentUser: User = Depends(getCurrentUser)): async def get_voice_settings(currentUser: User = Depends(getCurrentUser)):
"""Get voice settings for the current user (reads from UserVoicePreferences).""" """Get voice settings for the current user (reads from UserVoicePreferences)."""
from modules.datamodels.datamodelUam import UserVoicePreferences from modules.datamodels.datamodelUam import UserVoicePreferences
from modules.security.rootAccess import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface() rootInterface = getRootInterface()
userId = str(currentUser.id) userId = str(currentUser.id)
@ -464,7 +464,7 @@ async def save_voice_settings(
): ):
"""Save voice settings for the current user (writes to UserVoicePreferences).""" """Save voice settings for the current user (writes to UserVoicePreferences)."""
from modules.datamodels.datamodelUam import UserVoicePreferences, _normalizeTtsVoiceMap from modules.datamodels.datamodelUam import UserVoicePreferences, _normalizeTtsVoiceMap
from modules.security.rootAccess import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface() rootInterface = getRootInterface()
userId = str(currentUser.id) userId = str(currentUser.id)

View file

@ -48,6 +48,7 @@ async def runAgentLoop(
conversationHistory: List[Dict[str, Any]] = None, conversationHistory: List[Dict[str, Any]] = None,
persistRoundMemoryFn: Callable[..., Awaitable[None]] = None, persistRoundMemoryFn: Callable[..., Awaitable[None]] = None,
getExternalMemoryKeysFn: Callable[[], List[str]] = None, getExternalMemoryKeysFn: Callable[[], List[str]] = None,
systemPromptOverride: str = None,
) -> AsyncGenerator[AgentEvent, None]: ) -> AsyncGenerator[AgentEvent, None]:
"""Run the agent loop. Yields AgentEvent for each step (SSE-ready). """Run the agent loop. Yields AgentEvent for each step (SSE-ready).
@ -74,15 +75,19 @@ async def runAgentLoop(
featureInstanceId=featureInstanceId featureInstanceId=featureInstanceId
) )
tools = toolRegistry.getTools() activeToolSet = config.toolSet if config else None
toolDefinitions = toolRegistry.formatToolsForFunctionCalling() tools = toolRegistry.getTools(toolSet=activeToolSet)
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
# Text-based tool descriptions are ONLY used as fallback when native function # Text-based tool descriptions are ONLY used as fallback when native function
# calling is unavailable. Including both creates conflicting instructions # calling is unavailable. Including both creates conflicting instructions
# (text ```tool_call format vs native tool_use blocks) and can cause the model # (text ```tool_call format vs native tool_use blocks) and can cause the model
# to respond with plain text instead of actual tool calls. # to respond with plain text instead of actual tool calls.
toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt() toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
if systemPromptOverride:
systemPrompt = systemPromptOverride
else:
systemPrompt = buildSystemPrompt(tools, toolsText, userLanguage=userLanguage) systemPrompt = buildSystemPrompt(tools, toolsText, userLanguage=userLanguage)
conversation = ConversationManager(systemPrompt) conversation = ConversationManager(systemPrompt)
if conversationHistory: if conversationHistory:
@ -168,7 +173,7 @@ async def runAgentLoop(
temperature=config.temperature temperature=config.temperature
), ),
messages=conversation.messages, messages=conversation.messages,
tools=toolDefinitions tools=toolDefinitions if toolDefinitions else None,
) )
try: try:

View file

@ -132,6 +132,8 @@ class AgentService:
additionalTools: List[Dict[str, Any]] = None, additionalTools: List[Dict[str, Any]] = None,
userLanguage: str = "", userLanguage: str = "",
conversationHistory: List[Dict[str, Any]] = None, conversationHistory: List[Dict[str, Any]] = None,
buildRagContextFn: Callable = None,
systemPromptOverride: str = None,
) -> AsyncGenerator[AgentEvent, None]: ) -> AsyncGenerator[AgentEvent, None]:
"""Run an agent with the given prompt and tools. """Run an agent with the given prompt and tools.
@ -144,6 +146,8 @@ class AgentService:
additionalTools: Extra tool definitions to register dynamically additionalTools: Extra tool definitions to register dynamically
userLanguage: ISO 639-1 language code; falls back to user.language from profile userLanguage: ISO 639-1 language code; falls back to user.language from profile
conversationHistory: Prior messages for follow-up context conversationHistory: Prior messages for follow-up context
buildRagContextFn: Optional custom RAG context builder (overrides default)
systemPromptOverride: Optional system prompt override (replaces generated prompt)
Yields: Yields:
AgentEvent for each step (SSE-ready) AgentEvent for each step (SSE-ready)
@ -163,6 +167,7 @@ class AgentService:
aiCallFn = self._createAiCallFn() aiCallFn = self._createAiCallFn()
aiCallStreamFn = self._createAiCallStreamFn() aiCallStreamFn = self._createAiCallStreamFn()
getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId) getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId)
if buildRagContextFn is None:
buildRagContextFn = self._createBuildRagContextFn() buildRagContextFn = self._createBuildRagContextFn()
persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId) persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId)
getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId) getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId)
@ -183,6 +188,7 @@ class AgentService:
conversationHistory=conversationHistory, conversationHistory=conversationHistory,
persistRoundMemoryFn=persistRoundMemoryFn, persistRoundMemoryFn=persistRoundMemoryFn,
getExternalMemoryKeysFn=getExternalMemoryKeysFn, getExternalMemoryKeysFn=getExternalMemoryKeysFn,
systemPromptOverride=systemPromptOverride,
): ):
if event.type == AgentEventTypeEnum.AGENT_SUMMARY: if event.type == AgentEventTypeEnum.AGENT_SUMMARY:
await self._persistTrace(workflowId, event.data or {}) await self._persistTrace(workflowId, event.data or {})
@ -2610,54 +2616,54 @@ def _registerCoreTools(registry: ToolRegistry, services):
if not voiceName: if not voiceName:
try: try:
from modules.datamodels.datamodelUam import UserVoicePreferences from modules.datamodels.datamodelUam import UserVoicePreferences
from modules.security.rootAccess import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
userId = context.get("userId", "") userId = context.get("userId", "")
if userId: if userId:
rootIf = getRootInterface() rootIf = getRootInterface()
prefRecords = rootIf.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
if not prefRecords and mandateId:
prefRecords = rootIf.db.getRecordset( prefRecords = rootIf.db.getRecordset(
UserVoicePreferences, UserVoicePreferences,
recordFilter={"userId": userId} recordFilter={"userId": userId}
) )
if prefRecords: if prefRecords:
vs = prefRecords[0] if isinstance(prefRecords[0], dict) else prefRecords[0].model_dump() if hasattr(prefRecords[0], "model_dump") else prefRecords[0] allPrefs = [
voiceMap = vs.get("ttsVoiceMap", {}) or {} r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else r
if isinstance(voiceMap, dict) and voiceMap: for r in prefRecords
selectedKey = None ]
selectedVoiceEntry = None _mid = str(mandateId or "").strip()
baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else "" 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: def _resolveVoiceFromMap(prefDict, lang):
selectedKey = language vm = (prefDict or {}).get("ttsVoiceMap", {}) or {}
selectedVoiceEntry = voiceMap[language] 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 = ( voiceName = (
selectedVoiceEntry.get("voiceName") _resolveVoiceFromMap(scopedPref, language)
if isinstance(selectedVoiceEntry, dict) or _resolveVoiceFromMap(globalPref, language)
else selectedVoiceEntry or _resolveVoiceFromMap(allPrefs[0], language)
) )
logger.info( if not voiceName:
f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')" for candidate in [globalPref, scopedPref, allPrefs[0]]:
) if candidate and candidate.get("ttsVoice") and candidate.get("ttsLanguage") == language:
if not voiceName and vs.get("ttsVoice") and vs.get("ttsLanguage") == language: voiceName = candidate["ttsVoice"]
voiceName = vs["ttsVoice"] break
if voiceName:
logger.info(f"textToSpeech: using configured voice '{voiceName}' for language '{language}'")
except Exception as prefErr: except Exception as prefErr:
logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}") logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}")
@ -3416,3 +3422,21 @@ def _registerCoreTools(registry: ToolRegistry, services):
}, },
readOnly=True, 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"

View file

@ -125,20 +125,22 @@ class ToolRegistry:
durationMs=durationMs durationMs=durationMs
) )
def formatToolsForPrompt(self) -> str: def formatToolsForPrompt(self, toolSet: str = None) -> str:
"""Format all tools as text for system prompt (text-based fallback).""" """Format tools as text for system prompt (text-based fallback)."""
tools = self.getTools(toolSet=toolSet) if toolSet else list(self._tools.values())
parts = [] parts = []
for tool in self._tools.values(): for tool in tools:
paramStr = ", ".join( paramStr = ", ".join(
f"{k}: {v}" for k, v in tool.parameters.items() f"{k}: {v}" for k, v in tool.parameters.items()
) if tool.parameters else "none" ) if tool.parameters else "none"
parts.append(f"- **{tool.name}**: {tool.description}\n Parameters: {{{paramStr}}}") parts.append(f"- **{tool.name}**: {tool.description}\n Parameters: {{{paramStr}}}")
return "\n".join(parts) return "\n".join(parts)
def formatToolsForFunctionCalling(self) -> List[Dict[str, Any]]: def formatToolsForFunctionCalling(self, toolSet: str = None) -> List[Dict[str, Any]]:
"""Format all tools as OpenAI-compatible function definitions for native function calling.""" """Format tools as OpenAI-compatible function definitions for native function calling."""
tools = self.getTools(toolSet=toolSet) if toolSet else list(self._tools.values())
functions = [] functions = []
for tool in self._tools.values(): for tool in tools:
functions.append({ functions.append({
"type": "function", "type": "function",
"function": { "function": {