unified data completed implementation
This commit is contained in:
parent
96c94fae57
commit
b33444e891
14 changed files with 698 additions and 731 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
316
modules/migration/migrateVoiceAndDocuments.py
Normal file
316
modules/migration/migrateVoiceAndDocuments.py
Normal 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}
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 interface
|
||||
voiceInterface = _getVoiceInterface(currentUser)
|
||||
|
||||
# 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)}"
|
||||
)
|
||||
"""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)
|
||||
|
||||
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()}}
|
||||
|
||||
|
||||
@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}")
|
||||
|
||||
# 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}"
|
||||
)
|
||||
|
||||
# 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"
|
||||
|
||||
# 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)}"
|
||||
)
|
||||
"""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)
|
||||
|
||||
allowedFields = {
|
||||
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
|
||||
"translationSourceLanguage", "translationTargetLanguage",
|
||||
}
|
||||
updateData = {k: v for k, v in settings.items() if k in allowedFields}
|
||||
|
||||
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())
|
||||
|
||||
return {"success": True, "message": "Voice settings saved successfully", "data": updateData}
|
||||
|
||||
# =========================================================================
|
||||
# STT Streaming WebSocket — generic, used by all features
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue