fixed voice feat commcoach
This commit is contained in:
parent
5a40b54524
commit
0a5fa20cb8
12 changed files with 932 additions and 200 deletions
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
223
modules/features/commcoach/serviceCommcoachIndexer.py
Normal file
223
modules/features/commcoach/serviceCommcoachIndexer.py
Normal 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
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue