unified data completed implementation

This commit is contained in:
ValueOn AG 2026-03-24 16:39:25 +01:00
parent 96c94fae57
commit b33444e891
14 changed files with 698 additions and 731 deletions

View file

@ -170,8 +170,6 @@ class CoachingUserProfile(BaseModel):
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID")
preferredLanguage: str = Field(default="de-DE")
preferredVoice: Optional[str] = Field(default=None, description="Google TTS voice name")
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
dailyReminderEnabled: bool = Field(default=False)
emailSummaryEnabled: bool = Field(default=True)
@ -205,26 +203,6 @@ class CoachingPersona(BaseModel):
updatedAt: Optional[str] = Field(default=None)
# ============================================================================
# Iteration 2: Documents
# ============================================================================
class CoachingDocument(BaseModel):
"""A document attached to a coaching context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext")
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
instanceId: Optional[str] = Field(default=None)
fileName: str = Field(description="Original file name")
mimeType: str = Field(default="application/octet-stream")
fileSize: int = Field(default=0)
extractedText: Optional[str] = Field(default=None, description="Text content extracted from file")
summary: Optional[str] = Field(default=None, description="AI-generated summary")
fileRef: Optional[str] = Field(default=None, description="Reference to file in storage")
createdAt: Optional[str] = Field(default=None)
# ============================================================================
# Iteration 2: Badges / Gamification
# ============================================================================
@ -282,8 +260,6 @@ class UpdateTaskStatusRequest(BaseModel):
class UpdateProfileRequest(BaseModel):
preferredLanguage: Optional[str] = None
preferredVoice: Optional[str] = None
dailyReminderTime: Optional[str] = None
dailyReminderEnabled: Optional[bool] = None
emailSummaryEnabled: Optional[bool] = None

View file

@ -269,34 +269,6 @@ class CommcoachObjects:
from .datamodelCommcoach import CoachingPersona
return self.db.recordDelete(CoachingPersona, personaId)
# =========================================================================
# Documents
# =========================================================================
def getDocuments(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
from .datamodelCommcoach import CoachingDocument
records = self.db.getRecordset(CoachingDocument, recordFilter={"contextId": contextId, "userId": userId})
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
return records
def getDocument(self, documentId: str) -> Optional[Dict[str, Any]]:
from .datamodelCommcoach import CoachingDocument
records = self.db.getRecordset(CoachingDocument, recordFilter={"id": documentId})
return records[0] if records else None
def createDocument(self, data: Dict[str, Any]) -> Dict[str, Any]:
from .datamodelCommcoach import CoachingDocument
data["createdAt"] = getIsoTimestamp()
return self.db.recordCreate(CoachingDocument, data)
def updateDocument(self, documentId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
from .datamodelCommcoach import CoachingDocument
return self.db.recordModify(CoachingDocument, documentId, updates)
def deleteDocument(self, documentId: str) -> bool:
from .datamodelCommcoach import CoachingDocument
return self.db.recordDelete(CoachingDocument, documentId)
# =========================================================================
# Badges
# =========================================================================

View file

@ -61,18 +61,13 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.commcoach.CoachingUserProfile",
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "preferredLanguage"]}
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]}
},
{
"objectKey": "data.feature.commcoach.CoachingPersona",
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
},
{
"objectKey": "data.feature.commcoach.CoachingDocument",
"label": {"en": "Coaching Document", "de": "Coaching-Dokument", "fr": "Document coaching"},
"meta": {"table": "CoachingDocument", "fields": ["id", "contextId", "fileName"]}
},
{
"objectKey": "data.feature.commcoach.CoachingBadge",
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},

View file

@ -26,7 +26,7 @@ from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
CoachingTask, CoachingTaskStatus,
CoachingPersona, CoachingDocument, CoachingBadge,
CoachingPersona, CoachingBadge,
CreateContextRequest, UpdateContextRequest,
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
UpdateProfileRequest,
@ -334,9 +334,8 @@ async def startSession(
try:
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
profile = interface.getProfile(userId, instanceId)
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
voiceName = profile.get("preferredVoice") if profile else None
from .serviceCommcoach import _getUserVoicePrefs
language, voiceName = _getUserVoicePrefs(userId, mandateId)
from .serviceCommcoach import _stripMarkdownForTts
ttsResult = await voiceInterface.textToSpeech(
text=_stripMarkdownForTts(greetingText),
@ -574,8 +573,8 @@ async def sendAudioStream(
if not audioBody:
raise HTTPException(status_code=400, detail="No audio data received")
profile = interface.getProfile(str(context.user.id), instanceId)
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
from .serviceCommcoach import _getUserVoicePrefs
language, _ = _getUserVoicePrefs(str(context.user.id), mandateId)
contextId = session.get("contextId")
service = CommcoachService(context.user, mandateId, instanceId)
@ -839,73 +838,6 @@ async def updateProfile(
return {"profile": updated}
# =========================================================================
# Voice Endpoints
# =========================================================================
@router.get("/{instanceId}/voice/languages")
@limiter.limit("30/minute")
async def getVoiceLanguages(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
mandateId = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
languagesResult = await voiceInterface.getAvailableLanguages()
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
return {"languages": languageList}
@router.get("/{instanceId}/voice/voices")
@limiter.limit("30/minute")
async def getVoiceVoices(
request: Request,
instanceId: str,
language: str = "de-DE",
context: RequestContext = Depends(getRequestContext),
):
mandateId = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
voicesResult = await voiceInterface.getAvailableVoices(language)
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
return {"voices": voiceList}
@router.post("/{instanceId}/voice/tts")
@limiter.limit("10/minute")
async def testVoice(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""TTS preview / voice test."""
mandateId = _validateInstanceAccess(instanceId, context)
body = await request.json()
text = body.get("text", "Hallo, ich bin dein Coaching-Assistent.")
language = body.get("language", "de-DE")
voiceId = body.get("voiceId")
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
try:
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
if result and isinstance(result, dict):
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
).decode()
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
return {"success": False, "error": "TTS returned no audio"}
except Exception as e:
logger.error(f"Voice test failed: {e}")
raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}")
# =========================================================================
# Export Endpoints (Iteration 2)
# =========================================================================
@ -1074,202 +1006,6 @@ async def deletePersonaRoute(
return {"deleted": True}
# =========================================================================
# Document Endpoints (Iteration 2)
# =========================================================================
@router.get("/{instanceId}/contexts/{contextId}/documents")
@limiter.limit("60/minute")
async def listDocuments(
request: Request,
instanceId: str,
contextId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
docs = interface.getDocuments(contextId, userId)
return {"documents": docs}
@router.post("/{instanceId}/contexts/{contextId}/documents")
@limiter.limit("10/minute")
async def uploadDocument(
request: Request,
instanceId: str,
contextId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Upload a document and bind it to a context. Stores file in Management DB."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
_validateOwnership(ctx, context)
form = await request.form()
file = form.get("file")
if not file or not hasattr(file, "read"):
raise HTTPException(status_code=400, detail="No file uploaded")
content = await file.read()
fileName = getattr(file, "filename", "document")
mimeType = getattr(file, "content_type", "application/octet-stream")
fileSize = len(content)
if not content:
raise HTTPException(status_code=400, detail="Leere Datei hochgeladen")
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
mgmtInterface = interfaceDbManagement.getInterface(currentUser=context.user)
fileItem, _dupType = mgmtInterface.saveUploadedFile(content, fileName)
fileRef = fileItem.id
extractedText = _extractText(content, mimeType, fileName)
summary = None
if extractedText and len(extractedText.strip()) > 50:
try:
from .serviceCommcoach import CommcoachService
service = CommcoachService(context.user, mandateId, instanceId)
aiResp = await service._callAi(
"Du fasst Dokumente in 2-3 Saetzen zusammen.",
f"Fasse folgendes Dokument zusammen:\n\n{extractedText[:3000]}"
)
if aiResp and aiResp.errorCount == 0 and aiResp.content:
summary = aiResp.content.strip()
except Exception as e:
logger.warning(f"Document summary failed: {e}")
docData = CoachingDocument(
contextId=contextId,
userId=userId,
mandateId=mandateId,
instanceId=instanceId,
fileName=fileName,
mimeType=mimeType,
fileSize=fileSize,
extractedText=extractedText[:10000] if extractedText else None,
summary=summary,
fileRef=fileRef,
).model_dump()
created = interface.createDocument(docData)
return {"document": created}
@router.delete("/{instanceId}/documents/{documentId}")
@limiter.limit("10/minute")
async def deleteDocumentRoute(
request: Request,
instanceId: str,
documentId: str,
context: RequestContext = Depends(getRequestContext),
):
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
doc = interface.getDocument(documentId)
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
_validateOwnership(doc, context)
fileRef = doc.get("fileRef")
if fileRef:
try:
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
mgmtInterface = interfaceDbManagement.getInterface(
currentUser=context.user, mandateId=mandateId, featureInstanceId=instanceId
)
mgmtInterface.deleteFile(fileRef)
except Exception as e:
logger.warning(f"Failed to delete file {fileRef}: {e}")
interface.deleteDocument(documentId)
return {"deleted": True}
def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]:
"""Extract text from uploaded file content (TXT, MD, HTML, PDF, DOCX, XLSX, PPTX)."""
import io
lowerName = fileName.lower()
try:
if mimeType in ("text/plain",) or lowerName.endswith(".txt"):
return content.decode("utf-8", errors="replace")
if mimeType in ("text/markdown",) or lowerName.endswith(".md"):
return content.decode("utf-8", errors="replace")
if mimeType in ("text/html",) or lowerName.endswith((".html", ".htm")):
from html.parser import HTMLParser
class _Strip(HTMLParser):
def __init__(self):
super().__init__()
self._parts: list[str] = []
def handle_data(self, d):
self._parts.append(d)
def result(self):
return " ".join(self._parts)
parser = _Strip()
parser.feed(content.decode("utf-8", errors="replace"))
return parser.result()
if "pdf" in mimeType or lowerName.endswith(".pdf"):
try:
from PyPDF2 import PdfReader
reader = PdfReader(io.BytesIO(content))
return "".join(page.extract_text() or "" for page in reader.pages)
except ImportError:
logger.warning("PyPDF2 not installed, cannot extract PDF text")
return None
if "wordprocessingml" in mimeType or lowerName.endswith(".docx"):
try:
from docx import Document
doc = Document(io.BytesIO(content))
return "\n".join(p.text for p in doc.paragraphs if p.text)
except ImportError:
logger.warning("python-docx not installed, cannot extract DOCX text")
return None
if "spreadsheetml" in mimeType or lowerName.endswith(".xlsx"):
try:
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
parts: list[str] = []
for ws in wb.worksheets:
for row in ws.iter_rows(values_only=True):
cells = [str(c) for c in row if c is not None]
if cells:
parts.append("\t".join(cells))
return "\n".join(parts)
except ImportError:
logger.warning("openpyxl not installed, cannot extract XLSX text")
return None
if "presentationml" in mimeType or lowerName.endswith(".pptx"):
try:
from pptx import Presentation
prs = Presentation(io.BytesIO(content))
parts = []
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame:
parts.append(shape.text_frame.text)
return "\n".join(parts)
except ImportError:
logger.warning("python-pptx not installed, cannot extract PPTX text")
return None
logger.info(f"No text extractor for {fileName} (mime={mimeType})")
except Exception as e:
logger.warning(f"Text extraction failed for {fileName}: {e}")
return None
# =========================================================================
# Badge + Score History Endpoints (Iteration 2)
# =========================================================================

View file

@ -42,6 +42,30 @@ from .serviceCommcoachContextRetrieval import (
logger = logging.getLogger(__name__)
def _getUserVoicePrefs(userId: str, mandateId: Optional[str] = None) -> tuple:
"""Load voice language and voiceName from central UserVoicePreferences.
Returns (language, voiceName) tuple."""
try:
from modules.datamodels.datamodelUam import UserVoicePreferences
from modules.security.rootAccess import getRootInterface
rootIf = getRootInterface()
prefs = rootIf.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
if not prefs and mandateId:
prefs = rootIf.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId}
)
if prefs:
p = prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
return (p.get("ttsLanguage") or p.get("sttLanguage") or "de-DE", p.get("ttsVoice"))
except Exception as e:
logger.warning(f"Failed to load UserVoicePreferences for user={userId}: {e}")
return ("de-DE", None)
def _stripMarkdownForTts(text: str) -> str:
"""Strip markdown formatting so TTS reads clean speech text."""
t = text
@ -159,9 +183,7 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
import base64
voiceInterface = getVoiceInterface(currentUser, mandateId)
profile = interface.getProfile(str(currentUser.id), instanceId)
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
voiceName = profile.get("preferredVoice") if profile else None
language, voiceName = _getUserVoicePrefs(str(currentUser.id), mandateId)
ttsResult = await voiceInterface.textToSpeech(
text=_stripMarkdownForTts(speechText),
languageCode=language,
@ -196,60 +218,36 @@ def _resolveFileNameAndMime(title: str) -> tuple:
async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str,
mandateId: str, instanceId: str, interface, sessionId: str,
user=None):
"""Save a new document or update an existing one. Stores file in Management DB."""
from .datamodelCommcoach import CoachingDocument
"""Save a document as platform FileItem (no CoachingDocument)."""
try:
docId = doc.get("id")
title = doc.get("title", "Dokument")
content = doc.get("content", "")
contentBytes = content.encode("utf-8")
fileName, mimeType = _resolveFileNameAndMime(title)
fileRef = None
try:
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
mgmtInterface = interfaceDbManagement.getInterface(
currentUser=user, mandateId=mandateId, featureInstanceId=instanceId
)
fileItem = mgmtInterface.createFile(name=fileName, mimeType=mimeType, content=contentBytes)
mgmtInterface.createFileData(fileItem.id, contentBytes)
fileRef = fileItem.id
except Exception as e:
logger.warning(f"Failed to store document in file DB: {e}")
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
mgmtInterface = interfaceDbManagement.getInterface(
currentUser=user, mandateId=mandateId, featureInstanceId=instanceId
)
fileItem = mgmtInterface.createFile(name=fileName, mimeType=mimeType, content=contentBytes)
mgmtInterface.createFileData(fileItem.id, contentBytes)
from modules.datamodels.datamodelFiles import FileItem as FileItemModel
mgmtInterface.db.recordModify(FileItemModel, fileItem.id, {
"scope": "featureInstance",
"featureInstanceId": instanceId,
"mandateId": mandateId,
})
await emitSessionEvent(sessionId, "documentCreated", {
"id": fileItem.id, "fileName": fileName, "fileSize": len(contentBytes),
})
logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})")
if docId:
updates = {
"fileName": fileName,
"mimeType": mimeType,
"extractedText": content,
"summary": title,
"fileSize": len(contentBytes),
}
if fileRef:
updates["fileRef"] = fileRef
updated = interface.updateDocument(docId, updates)
if updated:
await emitSessionEvent(sessionId, "documentUpdated", updated)
logger.info(f"Document updated: {docId} ({title})")
else:
logger.warning(f"Document update failed, id not found: {docId}")
else:
docData = CoachingDocument(
contextId=contextId,
userId=userId,
mandateId=mandateId,
instanceId=instanceId,
fileName=fileName,
mimeType=mimeType,
fileSize=len(contentBytes),
extractedText=content,
summary=title,
fileRef=fileRef,
).model_dump()
created = interface.createDocument(docData)
await emitSessionEvent(sessionId, "documentCreated", created)
except Exception as e:
logger.warning(f"Failed to save/update document: {e}")
logger.warning(f"Failed to save document as FileItem: {e}")
async def _resolveDocumentIntent(combinedUserPrompt: str, docs: List[Dict[str, Any]], callAiFn) -> Dict[str, Any]:
@ -269,17 +267,60 @@ async def _resolveDocumentIntent(combinedUserPrompt: str, docs: List[Dict[str, A
return {"read": [], "update": [], "create": [], "noDocumentAction": True}
def _loadDocumentContents(docIds: List[str], interface) -> List[Dict[str, Any]]:
"""Load full extractedText for the given document IDs."""
results = []
for docId in docIds[:DOC_INTENT_MAX_DOCS]:
doc = interface.getDocument(docId)
if doc and doc.get("extractedText"):
results.append({
"id": doc.get("id", ""),
"title": doc.get("summary") or doc.get("fileName", ""),
"content": doc.get("extractedText", "")[:DOC_CONTENT_MAX_CHARS],
def _getPlatformFileList(mandateId: str = None, instanceId: str = None) -> List[Dict[str, Any]]:
"""Get list of platform FileItems for this feature instance (for doc intent detection)."""
try:
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelFiles import FileItem
mgmtIf = interfaceDbManagement.getInterface(
currentUser=None, mandateId=mandateId, featureInstanceId=instanceId
)
records = mgmtIf.db.getRecordset(
FileItem, recordFilter={"featureInstanceId": instanceId}
) if instanceId else []
result = []
for r in records:
d = r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else {}
result.append({
"id": d.get("id", ""),
"fileName": d.get("fileName") or d.get("name") or "Dokument",
"summary": d.get("fileName") or "",
})
return result
except Exception as e:
logger.warning(f"Failed to load platform file list: {e}")
return []
def _loadDocumentContents(docIds: List[str], interface, mandateId: str = None, instanceId: str = None) -> List[Dict[str, Any]]:
"""Load file content for given IDs from platform FileItem store."""
results = []
try:
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelFiles import FileItem
mgmtIf = interfaceDbManagement.getInterface(
currentUser=None, mandateId=mandateId, featureInstanceId=instanceId
)
for fId in docIds[:DOC_INTENT_MAX_DOCS]:
fileRecords = mgmtIf.db.getRecordset(FileItem, recordFilter={"id": fId})
if fileRecords:
f = fileRecords[0] if isinstance(fileRecords[0], dict) else fileRecords[0].model_dump()
content = ""
try:
from modules.datamodels.datamodelKnowledge import FileContentIndex
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"fileId": fId})
if idxRecords:
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS]
except Exception:
pass
results.append({
"id": fId,
"title": f.get("fileName") or f.get("name") or "Dokument",
"content": content,
})
except Exception as e:
logger.warning(f"Failed to load document contents from platform: {e}")
return results
@ -319,20 +360,42 @@ def _resolvePersona(session: Optional[Dict[str, Any]], interface) -> Optional[Di
return None
def _getDocumentSummaries(contextId: str, userId: str, interface) -> Optional[List[str]]:
"""Get document summaries for context to include in the AI prompt."""
def _getDocumentSummaries(contextId: str, userId: str, interface,
mandateId: str = None, instanceId: str = None) -> Optional[List[str]]:
"""Get document summaries from platform FileItems (UDL) for the coaching instance."""
try:
docs = interface.getDocuments(contextId, userId)
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelFiles import FileItem
mgmtIf = interfaceDbManagement.getInterface(
currentUser=None, mandateId=mandateId, featureInstanceId=instanceId
)
files = mgmtIf.db.getRecordset(
FileItem, recordFilter={"featureInstanceId": instanceId}
) if instanceId else []
summaries = []
for doc in docs[:5]:
summary = doc.get("summary")
if summary:
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {summary}")
elif doc.get("extractedText"):
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...")
for f in files[:10]:
fData = f if isinstance(f, dict) else f.model_dump() if hasattr(f, "model_dump") else {}
name = fData.get("fileName") or fData.get("name") or "Dokument"
fId = fData.get("id")
snippet = None
if fId:
try:
from modules.datamodels.datamodelKnowledge import FileContentIndex
idxRecords = mgmtIf.db.getRecordset(
FileContentIndex, recordFilter={"fileId": fId}
)
if idxRecords:
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
snippet = (idx.get("extractedText") or "")[:200]
except Exception:
pass
if snippet:
summaries.append(f"[{name}] {snippet}...")
else:
summaries.append(f"[{name}]")
return summaries if summaries else None
except Exception as e:
logger.warning(f"Failed to load document summaries for context {contextId}: {e}")
logger.warning(f"Failed to load platform file summaries for instance {instanceId}: {e}")
return None
@ -427,18 +490,22 @@ class CommcoachService:
)
persona = _resolvePersona(session, interface)
documentSummaries = _getDocumentSummaries(contextId, self.userId, interface)
documentSummaries = _getDocumentSummaries(
contextId, self.userId, interface, mandateId=self.mandateId, instanceId=self.instanceId
)
# Document intent detection (pre-AI-call)
referencedDocumentContents = None
allDocs = interface.getDocuments(contextId, self.userId) if documentSummaries else []
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)
referencedDocumentContents = _loadDocumentContents(
docIdsToLoad, interface, mandateId=self.mandateId, instanceId=self.instanceId
)
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
context,
@ -536,7 +603,9 @@ class CommcoachService:
session = interface.getSession(sessionId)
persona = _resolvePersona(session, interface)
documentSummaries = _getDocumentSummaries(contextId, self.userId, interface)
documentSummaries = _getDocumentSummaries(
contextId, self.userId, interface, mandateId=self.mandateId, instanceId=self.instanceId
)
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
context, previousMessages, tasks,

View file

@ -172,20 +172,48 @@ def searchSessionsByTopic(
def searchSessionsByTopicRag(
sessions: List[Dict[str, Any]],
query: str,
maxResults: int = TOPIC_SEARCH_MAX_RESULTS,
embeddingProvider: Optional[Any] = None,
userId: str,
instanceId: str,
mandateId: str = None,
queryVector: List[float] = None,
) -> List[Dict[str, Any]]:
"""Search using platform RAG (semantic search across mandate-wide knowledge data).
Requires a pre-computed queryVector (embedding). The caller is responsible
for generating the embedding via AiService.callEmbedding before invoking this.
"""
Phase 7 RAG: Semantic search via embeddings.
When embeddingProvider is None, falls back to keyword search.
Future: Pass embeddingProvider that has embed(text) -> vector and similarity search.
"""
if embeddingProvider is None:
return searchSessionsByTopic(sessions, query, maxResults)
# TODO: When embedding API exists: embed query, embed session summaries, cosine similarity
return searchSessionsByTopic(sessions, query, maxResults)
if not queryVector:
logger.warning("searchSessionsByTopicRag called without queryVector, skipping RAG search")
return []
try:
from modules.interfaces.interfaceDbKnowledge import getInterface as _getKnowledgeInterface
knowledgeDb = _getKnowledgeInterface()
results = knowledgeDb.semanticSearch(
queryVector=queryVector,
userId=userId,
featureInstanceId=instanceId,
mandateId=mandateId,
isSysAdmin=False,
limit=TOPIC_SEARCH_MAX_RESULTS,
)
formatted = []
for r in (results or []):
rData = r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else {}
contextRef = rData.get("contextRef") or {}
formatted.append({
"source": "rag",
"content": rData.get("data") or rData.get("summary") or "",
"fileName": contextRef.get("containerPath") or "RAG-Ergebnis",
"score": rData.get("_score") or 0,
})
return formatted
except Exception as e:
logger.warning(f"RAG search failed for query '{query[:50]}': {e}")
return []
def buildSessionSummariesForPrompt(

View file

@ -136,7 +136,6 @@ class TestCoachingUserProfile:
profile = CoachingUserProfile(
userId="u1", mandateId="m1", instanceId="i1",
)
assert profile.preferredLanguage == "de-DE"
assert profile.dailyReminderEnabled is False
assert profile.emailSummaryEnabled is True
assert profile.streakDays == 0

View file

@ -108,6 +108,13 @@ def initBootstrap(db: DatabaseConnector) -> None:
except Exception as e:
logger.error(f"Root user migration failed: {e}")
# Run voice & documents migration (one-time, sets completion flag)
try:
from modules.migration.migrateVoiceAndDocuments import migrateVoiceAndDocuments
migrateVoiceAndDocuments(db)
except Exception as e:
logger.error(f"Voice & documents migration failed: {e}")
# After migration: root mandate is purely technical — no feature instances
if not migrationDone and mandateId:
initRootMandateFeatures(db, mandateId)

View file

@ -11,9 +11,7 @@ import logging
from typing import AsyncGenerator, Callable, Dict, Any, Optional, List
from modules.connectors.connectorVoiceGoogle import ConnectorGoogleSpeech
from modules.datamodels.datamodelVoice import VoiceSettings
from modules.datamodels.datamodelUam import User
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
@ -335,123 +333,6 @@ class VoiceObjects:
"error": str(e)
}
# Voice Settings Management
def getVoiceSettings(self, userId: str) -> Optional[VoiceSettings]:
"""
Get voice settings for a user.
Args:
userId: User ID to get settings for
Returns:
VoiceSettings object or None if not found
"""
try:
# This would typically query the database
# For now, return None as this is handled by the database interface
logger.debug(f"Getting voice settings for user: {userId}")
return None
except Exception as e:
logger.error(f"❌ Error getting voice settings: {e}")
return None
def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Optional[VoiceSettings]:
"""
Create new voice settings.
Args:
settingsData: Dictionary containing voice settings data
Returns:
Created VoiceSettings object or None if failed
"""
try:
logger.info(f"Creating voice settings: {settingsData}")
# Ensure mandateId is set from context if not provided
if "mandateId" not in settingsData or not settingsData["mandateId"]:
if not self.mandateId:
raise ValueError("mandateId is required but not provided and context has no mandateId")
settingsData["mandateId"] = self.mandateId
# Add timestamps
currentTime = getUtcTimestamp()
settingsData["creationDate"] = currentTime
settingsData["lastModified"] = currentTime
# Create VoiceSettings object
voiceSettings = VoiceSettings(**settingsData)
logger.info(f"✅ Voice settings created: {voiceSettings.id}")
return voiceSettings
except Exception as e:
logger.error(f"❌ Error creating voice settings: {e}")
return None
def updateVoiceSettings(self, userId: str, settingsData: Dict[str, Any]) -> Optional[VoiceSettings]:
"""
Update existing voice settings.
Args:
userId: User ID to update settings for
settingsData: Dictionary containing updated voice settings data
Returns:
Updated VoiceSettings object or None if failed
"""
try:
logger.info(f"Updating voice settings for user {userId}: {settingsData}")
# Add last modified timestamp
settingsData["lastModified"] = getUtcTimestamp()
# Create updated VoiceSettings object
voiceSettings = VoiceSettings(**settingsData)
logger.info(f"✅ Voice settings updated: {voiceSettings.id}")
return voiceSettings
except Exception as e:
logger.error(f"❌ Error updating voice settings: {e}")
return None
def getOrCreateVoiceSettings(self, userId: str) -> Optional[VoiceSettings]:
"""
Get existing voice settings or create default ones.
Args:
userId: User ID to get/create settings for
Returns:
VoiceSettings object
"""
try:
# Try to get existing settings
existingSettings = self.getVoiceSettings(userId)
if existingSettings:
return existingSettings
# Create default settings if none exist
defaultSettings = {
"userId": userId,
"mandateId": self.mandateId,
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-Wavenet-A",
"translationEnabled": True,
"targetLanguage": "en-US"
}
return self.createVoiceSettings(defaultSettings)
except Exception as e:
logger.error(f"❌ Error getting or creating voice settings: {e}")
return None
# Language and Voice Information
async def getAvailableLanguages(self) -> Dict[str, Any]:

View file

@ -0,0 +1,316 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Migration: Voice settings consolidation and CoachingDocument scope-tagging.
Moves VoiceSettings (workspace DB) and CoachingUserProfile voice fields (commcoach DB)
into the unified UserVoicePreferences model, and tags CoachingDocument files with
featureInstance scope before deleting the legacy records.
Called once from bootstrap, sets a DB flag to prevent re-execution.
"""
import logging
import uuid
from typing import Dict, List, Optional
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.datamodels.datamodelUam import UserVoicePreferences
logger = logging.getLogger(__name__)
_MIGRATION_FLAG_KEY = "migration_voice_documents_completed"
def _isMigrationCompleted(db) -> bool:
"""Check if migration has already been executed."""
try:
from modules.datamodels.datamodelUam import Mandate
records = db.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
return len(records) > 0
except Exception:
return False
def _setMigrationCompleted(db) -> None:
"""Set flag that migration is completed (uses a settings-like record)."""
if _isMigrationCompleted(db):
return
try:
from modules.datamodels.datamodelUam import Mandate
flag = Mandate(name=_MIGRATION_FLAG_KEY, label="Migration completed", enabled=False, isSystem=True)
db.recordCreate(Mandate, flag)
logger.info("Migration flag set: voice & documents migration completed")
except Exception as e:
logger.error(f"Failed to set migration flag: {e}")
def _getRawRows(connector: DatabaseConnector, tableName: str, columns: List[str]) -> List[Dict]:
"""Read all rows from a table via raw SQL. Returns empty list if table doesn't exist."""
try:
connector._ensure_connection()
colList = ", ".join(f'"{c}"' for c in columns)
with connector.connection.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
(tableName,),
)
if cur.fetchone()["count"] == 0:
logger.info(f"Table '{tableName}' does not exist, skipping")
return []
cur.execute(f'SELECT {colList} FROM "{tableName}"')
return [dict(row) for row in cur.fetchall()]
except Exception as e:
logger.warning(f"Raw query on '{tableName}' failed: {e}")
try:
connector.connection.rollback()
except Exception:
pass
return []
def _deleteRawRow(connector: DatabaseConnector, tableName: str, rowId: str) -> bool:
"""Delete a single row by id via raw SQL."""
try:
connector._ensure_connection()
with connector.connection.cursor() as cur:
cur.execute(f'DELETE FROM "{tableName}" WHERE "id" = %s', (rowId,))
connector.connection.commit()
return True
except Exception as e:
logger.warning(f"Failed to delete row {rowId} from '{tableName}': {e}")
try:
connector.connection.rollback()
except Exception:
pass
return False
def _createDbConnector(dbName: str) -> Optional[DatabaseConnector]:
"""Create a DatabaseConnector for a named database, returns None on failure."""
try:
dbHost = APP_CONFIG.get("DB_HOST")
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
return DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbName,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
)
except Exception as e:
logger.warning(f"Could not connect to database '{dbName}': {e}")
return None
# ─── Part A ───────────────────────────────────────────────────────────────────
def _migrateVoiceSettings(db, wsDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
"""Migrate VoiceSettings records from poweron_workspace into UserVoicePreferences."""
rows = _getRawRows(wsDb, "VoiceSettings", [
"id", "userId", "mandateId", "ttsVoiceMap", "sttLanguage", "ttsLanguage", "ttsVoice",
])
if not rows:
logger.info("Part A: No VoiceSettings records found, skipping")
return
for row in rows:
userId = row.get("userId")
if not userId:
continue
existing = db.getRecordset(UserVoicePreferences, recordFilter={"userId": userId})
if existing:
stats["voiceSettingsSkipped"] += 1
if not dryRun:
_deleteRawRow(wsDb, "VoiceSettings", row["id"])
continue
if dryRun:
logger.info(f"[DRY RUN] Would create UserVoicePreferences for user {userId} from VoiceSettings")
stats["voiceSettingsCreated"] += 1
continue
try:
import json
ttsVoiceMap = row.get("ttsVoiceMap")
if isinstance(ttsVoiceMap, str):
try:
ttsVoiceMap = json.loads(ttsVoiceMap)
except (json.JSONDecodeError, TypeError):
ttsVoiceMap = None
prefs = UserVoicePreferences(
userId=userId,
mandateId=row.get("mandateId"),
ttsVoiceMap=ttsVoiceMap,
sttLanguage=row.get("sttLanguage", "de-DE"),
ttsLanguage=row.get("ttsLanguage", "de-DE"),
ttsVoice=row.get("ttsVoice"),
)
db.recordCreate(UserVoicePreferences, prefs)
stats["voiceSettingsCreated"] += 1
_deleteRawRow(wsDb, "VoiceSettings", row["id"])
except Exception as e:
logger.error(f"Part A: Failed to migrate VoiceSettings {row['id']}: {e}")
stats["errors"] += 1
# ─── Part B ───────────────────────────────────────────────────────────────────
def _migrateCoachingProfileVoice(db, ccDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
"""Migrate preferredLanguage/preferredVoice from CoachingUserProfile into UserVoicePreferences."""
rows = _getRawRows(ccDb, "CoachingUserProfile", [
"id", "userId", "mandateId", "preferredLanguage", "preferredVoice",
])
if not rows:
logger.info("Part B: No CoachingUserProfile records with voice data found, skipping")
return
for row in rows:
userId = row.get("userId")
prefLang = row.get("preferredLanguage")
prefVoice = row.get("preferredVoice")
if not userId or (not prefLang and not prefVoice):
continue
existing = db.getRecordset(UserVoicePreferences, recordFilter={"userId": userId})
if existing:
stats["coachingProfileSkipped"] += 1
continue
if dryRun:
logger.info(f"[DRY RUN] Would create UserVoicePreferences for user {userId} from CoachingUserProfile")
stats["coachingProfileCreated"] += 1
continue
try:
prefs = UserVoicePreferences(
userId=userId,
mandateId=row.get("mandateId"),
sttLanguage=prefLang or "de-DE",
ttsLanguage=prefLang or "de-DE",
ttsVoice=prefVoice,
)
db.recordCreate(UserVoicePreferences, prefs)
stats["coachingProfileCreated"] += 1
except Exception as e:
logger.error(f"Part B: Failed to migrate CoachingUserProfile {row['id']}: {e}")
stats["errors"] += 1
# ─── Part C ───────────────────────────────────────────────────────────────────
def _migrateCoachingDocuments(ccDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
"""Tag FileItem/FileContentIndex with featureInstance scope for each CoachingDocument."""
from modules.datamodels.datamodelFiles import FileItem
from modules.datamodels.datamodelKnowledge import FileContentIndex
rows = _getRawRows(ccDb, "CoachingDocument", [
"id", "fileRef", "instanceId",
])
if not rows:
logger.info("Part C: No CoachingDocument records found, skipping")
return
mgmtDb = _createDbConnector("poweron_management")
knowledgeDb = _createDbConnector("poweron_knowledge")
if not mgmtDb:
logger.error("Part C: Cannot connect to poweron_management, aborting document migration")
return
for row in rows:
fileRef = row.get("fileRef")
instanceId = row.get("instanceId")
docId = row.get("id")
if not fileRef:
if not dryRun:
_deleteRawRow(ccDb, "CoachingDocument", docId)
continue
if dryRun:
logger.info(f"[DRY RUN] Would tag FileItem {fileRef} with featureInstanceId={instanceId}")
stats["documentsTagged"] += 1
continue
try:
fileRecords = mgmtDb.getRecordset(FileItem, recordFilter={"id": fileRef})
if fileRecords:
updateData = {"scope": "featureInstance"}
if instanceId:
updateData["featureInstanceId"] = instanceId
mgmtDb.recordModify(FileItem, fileRef, updateData)
stats["documentsTagged"] += 1
else:
logger.warning(f"Part C: FileItem {fileRef} not found in management DB")
if knowledgeDb:
fciRecords = knowledgeDb.getRecordset(FileContentIndex, recordFilter={"id": fileRef})
if fciRecords:
fciUpdate = {"scope": "featureInstance"}
if instanceId:
fciUpdate["featureInstanceId"] = instanceId
knowledgeDb.recordModify(FileContentIndex, fileRef, fciUpdate)
_deleteRawRow(ccDb, "CoachingDocument", docId)
except Exception as e:
logger.error(f"Part C: Failed to migrate CoachingDocument {docId}: {e}")
stats["errors"] += 1
# ─── Main entry ───────────────────────────────────────────────────────────────
def migrateVoiceAndDocuments(db, dryRun: bool = False) -> dict:
"""
Migrate VoiceSettings + CoachingUserProfile voice fields into UserVoicePreferences,
and tag CoachingDocument files with featureInstance scope.
Args:
db: Root database connector (poweron_app)
dryRun: If True, log actions without making changes
Returns:
Summary dict with migration statistics
"""
if _isMigrationCompleted(db):
logger.info("Voice & documents migration already completed, skipping")
return {"status": "already_completed"}
stats = {
"voiceSettingsCreated": 0,
"voiceSettingsSkipped": 0,
"coachingProfileCreated": 0,
"coachingProfileSkipped": 0,
"documentsTagged": 0,
"errors": 0,
"dryRun": dryRun,
}
wsDb = _createDbConnector("poweron_workspace")
ccDb = _createDbConnector("poweron_commcoach")
# Part A
if wsDb:
_migrateVoiceSettings(db, wsDb, dryRun, stats)
else:
logger.warning("Skipping Part A: poweron_workspace DB unavailable")
# Part B
if ccDb:
_migrateCoachingProfileVoice(db, ccDb, dryRun, stats)
else:
logger.warning("Skipping Part B: poweron_commcoach DB unavailable")
# Part C
if ccDb:
_migrateCoachingDocuments(ccDb, dryRun, stats)
else:
logger.warning("Skipping Part C: poweron_commcoach DB unavailable")
if not dryRun:
_setMigrationCompleted(db)
logger.info(f"Voice & documents migration completed: {stats}")
return {"status": "completed", **stats}

View file

@ -4,7 +4,7 @@
Routes for local security and authentication.
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body, Query
from fastapi.security import OAuth2PasswordRequestForm
import logging
from typing import Dict, Any
@ -816,22 +816,19 @@ def getVoicePreferences(
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
try:
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserVoicePreferences
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserVoicePreferences
mandateId = request.headers.get("X-Mandate-Id") or None
mandateId = request.headers.get("X-Mandate-Id") or None
userId = str(currentUser.id)
prefs = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": str(currentUser.id), "mandateId": mandateId}
)
if prefs:
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
return UserVoicePreferences(userId=str(currentUser.id), mandateId=mandateId).model_dump()
except Exception as e:
logger.error(f"Error getting voice preferences: {e}")
return {"sttLanguage": "de-DE", "ttsLanguage": "de-DE"}
prefs = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
if prefs:
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
return UserVoicePreferences(userId=userId, mandateId=mandateId).model_dump()
@router.put("/voice-preferences")
@ -841,34 +838,87 @@ def updateVoicePreferences(
preferences: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Update user's voice/language preferences."""
try:
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserVoicePreferences
"""Update user's voice/language preferences (upsert)."""
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserVoicePreferences
mandateId = request.headers.get("X-Mandate-Id") or None
userId = str(currentUser.id)
mandateId = request.headers.get("X-Mandate-Id") or None
userId = str(currentUser.id)
existing = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
existing = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
allowedFields = {
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
"translationSourceLanguage", "translationTargetLanguage",
}
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
allowedFields = {
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
"translationSourceLanguage", "translationTargetLanguage",
}
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
if existing:
existingRecord = existing[0]
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
return {"message": "Updated", **updateData}
else:
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
return {"message": "Created", **(created if isinstance(created, dict) else created.model_dump())}
except Exception as e:
logger.error(f"Error updating voice preferences: {e}")
raise HTTPException(status_code=500, detail=str(e))
if existing:
existingRecord = existing[0]
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
updated = rootInterface.db.getRecordset(UserVoicePreferences, recordFilter={"id": existingId})
return updated[0] if updated else {"message": "Updated", **updateData}
else:
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
return created if isinstance(created, dict) else created.model_dump()
@router.get("/voice/languages")
@limiter.limit("120/minute")
async def getVoiceLanguages(
request: Request,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Return available TTS languages (user-level, no instance context needed)."""
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(currentUser)
languagesResult = await voiceInterface.getAvailableLanguages()
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
return {"languages": languageList}
@router.get("/voice/voices")
@limiter.limit("120/minute")
async def getVoiceVoices(
request: Request,
language: str = Query("de-DE"),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Return available TTS voices for a given language."""
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(currentUser)
voicesResult = await voiceInterface.getAvailableVoices(language)
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
return {"voices": voiceList}
@router.post("/voice/test")
@limiter.limit("30/minute")
async def testVoice(
request: Request,
body: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Test a specific voice with a sample text."""
import base64
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
text = body.get("text", "Hallo, das ist ein Stimmtest.")
language = body.get("language", "de-DE")
voiceId = body.get("voiceId")
voiceInterface = getVoiceInterface(currentUser)
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
if result and isinstance(result, dict):
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
).decode()
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
return {"success": False, "error": "TTS returned no audio"}

View file

@ -442,113 +442,50 @@ async def health_check(currentUser: User = Depends(getCurrentUser)):
@router.get("/settings")
async def get_voice_settings(currentUser: User = Depends(getCurrentUser)):
"""Get voice settings for the current user."""
try:
logger.info(f"Getting voice settings for user: {currentUser.id}")
"""Get voice settings for the current user (reads from UserVoicePreferences)."""
from modules.datamodels.datamodelUam import UserVoicePreferences
from modules.security.rootAccess import getRootInterface
rootInterface = getRootInterface()
userId = str(currentUser.id)
# Get voice interface
voiceInterface = _getVoiceInterface(currentUser)
prefs = rootInterface.db.getRecordset(
UserVoicePreferences, recordFilter={"userId": userId}
)
if prefs:
data = prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
return {"success": True, "data": {"user_settings": data}}
return {"success": True, "data": {"user_settings": UserVoicePreferences(userId=userId).model_dump()}}
# Get or create voice settings for the user
voice_settings = voiceInterface.getOrCreateVoiceSettings(currentUser.id)
if voice_settings:
# Return user settings
return {
"success": True,
"data": {
"user_settings": voice_settings.model_dump(),
"default_settings": {
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-Wavenet-A",
"translationEnabled": True,
"targetLanguage": "en-US"
}
}
}
else:
# Fallback to default settings if database fails
logger.warning("Failed to get voice settings from database, using defaults")
return {
"success": True,
"data": {
"user_settings": None,
"default_settings": {
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-Wavenet-A",
"translationEnabled": True,
"targetLanguage": "en-US"
}
}
}
except Exception as e:
logger.error(f"Error getting voice settings: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to get voice settings: {str(e)}"
)
@router.post("/settings")
async def save_voice_settings(
settings: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser)
):
"""Save voice settings for the current user."""
try:
logger.info(f"Saving voice settings for user: {currentUser.id}")
logger.info(f"Settings: {settings}")
"""Save voice settings for the current user (writes to UserVoicePreferences)."""
from modules.datamodels.datamodelUam import UserVoicePreferences
from modules.security.rootAccess import getRootInterface
rootInterface = getRootInterface()
userId = str(currentUser.id)
# Validate required settings
requiredFields = ["sttLanguage", "ttsLanguage", "ttsVoice"]
for field in requiredFields:
if field not in settings:
raise HTTPException(
status_code=400,
detail=f"Missing required field: {field}"
)
allowedFields = {
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
"translationSourceLanguage", "translationTargetLanguage",
}
updateData = {k: v for k, v in settings.items() if k in allowedFields}
# Set default values for optional fields if not provided
if "translationEnabled" not in settings:
settings["translationEnabled"] = True
if "targetLanguage" not in settings:
settings["targetLanguage"] = "en-US"
existing = rootInterface.db.getRecordset(
UserVoicePreferences, recordFilter={"userId": userId}
)
if existing:
existingRecord = existing[0]
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
else:
newPrefs = UserVoicePreferences(userId=userId, **updateData)
rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
# Get voice interface
voiceInterface = _getVoiceInterface(currentUser)
# Check if settings already exist for this user
existing_settings = voiceInterface.getVoiceSettings(currentUser.id)
if existing_settings:
# Update existing settings
logger.info(f"Updating existing voice settings for user {currentUser.id}")
updated_settings = voiceInterface.updateVoiceSettings(currentUser.id, settings)
logger.info(f"Voice settings updated for user {currentUser.id}: {updated_settings}")
else:
# Create new settings
logger.info(f"Creating new voice settings for user {currentUser.id}")
# Add userId to settings
settings["userId"] = currentUser.id
created_settings = voiceInterface.createVoiceSettings(settings)
logger.info(f"Voice settings created for user {currentUser.id}: {created_settings}")
return {
"success": True,
"message": "Voice settings saved successfully",
"data": settings
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error saving voice settings: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to save voice settings: {str(e)}"
)
return {"success": True, "message": "Voice settings saved successfully", "data": updateData}
# =========================================================================
# STT Streaming WebSocket — generic, used by all features

View file

@ -2517,55 +2517,55 @@ def _registerCoreTools(registry: ToolRegistry, services):
if not voiceName:
try:
from modules.features.workspace import interfaceFeatureWorkspace
featureInstanceId = context.get("featureInstanceId", "")
from modules.datamodels.datamodelUam import UserVoicePreferences
from modules.security.rootAccess import getRootInterface
userId = context.get("userId", "")
if userId:
wsIf = interfaceFeatureWorkspace.getInterface(
services.user,
mandateId=mandateId or None,
featureInstanceId=featureInstanceId or None,
rootIf = getRootInterface()
prefRecords = rootIf.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
vs = wsIf.getVoiceSettings(userId) if wsIf else None
if vs:
voiceMap = {}
if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap:
voiceMap = vs.ttsVoiceMap if isinstance(vs.ttsVoiceMap, dict) else {}
if not prefRecords and mandateId:
prefRecords = rootIf.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId}
)
if prefRecords:
vs = prefRecords[0] if isinstance(prefRecords[0], dict) else prefRecords[0].model_dump() if hasattr(prefRecords[0], "model_dump") else prefRecords[0]
voiceMap = vs.get("ttsVoiceMap", {}) or {}
if isinstance(voiceMap, dict) and voiceMap:
selectedKey = None
selectedVoiceEntry = None
baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else ""
selectedKey = None
selectedVoiceEntry = None
baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else ""
if isinstance(language, str) and language in voiceMap:
selectedKey = language
selectedVoiceEntry = voiceMap[language]
# 1) Exact match first (e.g. de-DE)
if isinstance(language, str) and language in voiceMap:
selectedKey = language
selectedVoiceEntry = voiceMap[language]
if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap:
selectedKey = baseLanguage
selectedVoiceEntry = voiceMap[baseLanguage]
# 2) Match short language key (e.g. de)
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
# 3) Match by same language family (e.g. de-CH -> de-DE mapping)
if selectedVoiceEntry is None and baseLanguage:
for mapKey, mapValue in voiceMap.items():
mapKeyNorm = str(mapKey).lower()
if mapKeyNorm == baseLanguage or mapKeyNorm.startswith(f"{baseLanguage}-"):
selectedKey = str(mapKey)
selectedVoiceEntry = mapValue
break
if selectedVoiceEntry is not None:
voiceName = (
selectedVoiceEntry.get("voiceName")
if isinstance(selectedVoiceEntry, dict)
else selectedVoiceEntry
)
logger.info(
f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')"
)
elif hasattr(vs, "ttsVoice") and vs.ttsVoice and hasattr(vs, "ttsLanguage") and vs.ttsLanguage == language:
voiceName = vs.ttsVoice
if selectedVoiceEntry is not None:
voiceName = (
selectedVoiceEntry.get("voiceName")
if isinstance(selectedVoiceEntry, dict)
else selectedVoiceEntry
)
logger.info(
f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')"
)
if not voiceName and vs.get("ttsVoice") and vs.get("ttsLanguage") == language:
voiceName = vs["ttsVoice"]
except Exception as prefErr:
logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}")

View file

@ -557,23 +557,24 @@ detectedIntent-Werte:
def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool]:
"""Neutralize the prompt text in an AiCallRequest.
Returns (modifiedRequest, wasNeutralized)."""
try:
neutralSvc = self._get_service("neutralization")
if not neutralSvc or not hasattr(neutralSvc, 'processText'):
return request, False
Returns (modifiedRequest, wasNeutralized).
Raises RuntimeError if neutralization is required but fails (fail-safe)."""
neutralSvc = self._get_service("neutralization")
if not neutralSvc or not hasattr(neutralSvc, 'processText'):
raise RuntimeError("Neutralization required but neutralization service is unavailable")
if request.prompt:
result = neutralSvc.processText(request.prompt)
if result and result.get("neutralized_text"):
request.prompt = result["neutralized_text"]
logger.debug("Neutralized prompt in AiCallRequest")
return request, True
if request.prompt:
result = neutralSvc.processText(request.prompt)
if result and result.get("neutralized_text"):
request.prompt = result["neutralized_text"]
logger.debug("Neutralized prompt in AiCallRequest")
return request, True
raise RuntimeError(
"Neutralization required but processText returned no neutralized_text — "
"AI call blocked to protect sensitive data"
)
return request, False
except Exception as e:
logger.warning(f"Request neutralization failed: {e}")
return request, False
return request, False
def _rehydrateResponse(self, responseText: str) -> str:
"""Replace neutralization placeholders with original values in AI response."""