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")
|
userId: str = Field(description="Owner user ID")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
instanceId: str = Field(description="Feature instance 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")
|
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
|
||||||
dailyReminderEnabled: bool = Field(default=False)
|
dailyReminderEnabled: bool = Field(default=False)
|
||||||
emailSummaryEnabled: bool = Field(default=True)
|
emailSummaryEnabled: bool = Field(default=True)
|
||||||
|
|
@ -205,26 +203,6 @@ class CoachingPersona(BaseModel):
|
||||||
updatedAt: Optional[str] = Field(default=None)
|
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
|
# Iteration 2: Badges / Gamification
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -282,8 +260,6 @@ class UpdateTaskStatusRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class UpdateProfileRequest(BaseModel):
|
class UpdateProfileRequest(BaseModel):
|
||||||
preferredLanguage: Optional[str] = None
|
|
||||||
preferredVoice: Optional[str] = None
|
|
||||||
dailyReminderTime: Optional[str] = None
|
dailyReminderTime: Optional[str] = None
|
||||||
dailyReminderEnabled: Optional[bool] = None
|
dailyReminderEnabled: Optional[bool] = None
|
||||||
emailSummaryEnabled: Optional[bool] = None
|
emailSummaryEnabled: Optional[bool] = None
|
||||||
|
|
|
||||||
|
|
@ -269,34 +269,6 @@ class CommcoachObjects:
|
||||||
from .datamodelCommcoach import CoachingPersona
|
from .datamodelCommcoach import CoachingPersona
|
||||||
return self.db.recordDelete(CoachingPersona, personaId)
|
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
|
# Badges
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -61,18 +61,13 @@ DATA_OBJECTS = [
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingUserProfile",
|
"objectKey": "data.feature.commcoach.CoachingUserProfile",
|
||||||
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
|
"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",
|
"objectKey": "data.feature.commcoach.CoachingPersona",
|
||||||
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
|
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
|
||||||
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
|
"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",
|
"objectKey": "data.feature.commcoach.CoachingBadge",
|
||||||
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},
|
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from .datamodelCommcoach import (
|
||||||
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
|
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
|
||||||
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
||||||
CoachingTask, CoachingTaskStatus,
|
CoachingTask, CoachingTaskStatus,
|
||||||
CoachingPersona, CoachingDocument, CoachingBadge,
|
CoachingPersona, CoachingBadge,
|
||||||
CreateContextRequest, UpdateContextRequest,
|
CreateContextRequest, UpdateContextRequest,
|
||||||
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
|
@ -334,9 +334,8 @@ async def startSession(
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
voiceInterface = getVoiceInterface(context.user, mandateId)
|
voiceInterface = getVoiceInterface(context.user, mandateId)
|
||||||
profile = interface.getProfile(userId, instanceId)
|
from .serviceCommcoach import _getUserVoicePrefs
|
||||||
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
language, voiceName = _getUserVoicePrefs(userId, mandateId)
|
||||||
voiceName = profile.get("preferredVoice") if profile else None
|
|
||||||
from .serviceCommcoach import _stripMarkdownForTts
|
from .serviceCommcoach import _stripMarkdownForTts
|
||||||
ttsResult = await voiceInterface.textToSpeech(
|
ttsResult = await voiceInterface.textToSpeech(
|
||||||
text=_stripMarkdownForTts(greetingText),
|
text=_stripMarkdownForTts(greetingText),
|
||||||
|
|
@ -574,8 +573,8 @@ async def sendAudioStream(
|
||||||
if not audioBody:
|
if not audioBody:
|
||||||
raise HTTPException(status_code=400, detail="No audio data received")
|
raise HTTPException(status_code=400, detail="No audio data received")
|
||||||
|
|
||||||
profile = interface.getProfile(str(context.user.id), instanceId)
|
from .serviceCommcoach import _getUserVoicePrefs
|
||||||
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
language, _ = _getUserVoicePrefs(str(context.user.id), mandateId)
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
contextId = session.get("contextId")
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
|
|
@ -839,73 +838,6 @@ async def updateProfile(
|
||||||
return {"profile": updated}
|
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)
|
# Export Endpoints (Iteration 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1074,202 +1006,6 @@ async def deletePersonaRoute(
|
||||||
return {"deleted": True}
|
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)
|
# Badge + Score History Endpoints (Iteration 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,30 @@ from .serviceCommcoachContextRetrieval import (
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _stripMarkdownForTts(text: str) -> str:
|
||||||
"""Strip markdown formatting so TTS reads clean speech text."""
|
"""Strip markdown formatting so TTS reads clean speech text."""
|
||||||
t = text
|
t = text
|
||||||
|
|
@ -159,9 +183,7 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
import base64
|
import base64
|
||||||
voiceInterface = getVoiceInterface(currentUser, mandateId)
|
voiceInterface = getVoiceInterface(currentUser, mandateId)
|
||||||
profile = interface.getProfile(str(currentUser.id), instanceId)
|
language, voiceName = _getUserVoicePrefs(str(currentUser.id), mandateId)
|
||||||
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
|
||||||
voiceName = profile.get("preferredVoice") if profile else None
|
|
||||||
ttsResult = await voiceInterface.textToSpeech(
|
ttsResult = await voiceInterface.textToSpeech(
|
||||||
text=_stripMarkdownForTts(speechText),
|
text=_stripMarkdownForTts(speechText),
|
||||||
languageCode=language,
|
languageCode=language,
|
||||||
|
|
@ -196,60 +218,36 @@ def _resolveFileNameAndMime(title: str) -> tuple:
|
||||||
async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str,
|
async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str,
|
||||||
mandateId: str, instanceId: str, interface, sessionId: str,
|
mandateId: str, instanceId: str, interface, sessionId: str,
|
||||||
user=None):
|
user=None):
|
||||||
"""Save a new document or update an existing one. Stores file in Management DB."""
|
"""Save a document as platform FileItem (no CoachingDocument)."""
|
||||||
from .datamodelCommcoach import CoachingDocument
|
|
||||||
try:
|
try:
|
||||||
docId = doc.get("id")
|
|
||||||
title = doc.get("title", "Dokument")
|
title = doc.get("title", "Dokument")
|
||||||
content = doc.get("content", "")
|
content = doc.get("content", "")
|
||||||
contentBytes = content.encode("utf-8")
|
contentBytes = content.encode("utf-8")
|
||||||
fileName, mimeType = _resolveFileNameAndMime(title)
|
fileName, mimeType = _resolveFileNameAndMime(title)
|
||||||
|
|
||||||
fileRef = None
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
try:
|
mgmtInterface = interfaceDbManagement.getInterface(
|
||||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
currentUser=user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
mgmtInterface = interfaceDbManagement.getInterface(
|
)
|
||||||
currentUser=user, mandateId=mandateId, featureInstanceId=instanceId
|
fileItem = mgmtInterface.createFile(name=fileName, mimeType=mimeType, content=contentBytes)
|
||||||
)
|
mgmtInterface.createFileData(fileItem.id, contentBytes)
|
||||||
fileItem = mgmtInterface.createFile(name=fileName, mimeType=mimeType, content=contentBytes)
|
|
||||||
mgmtInterface.createFileData(fileItem.id, contentBytes)
|
from modules.datamodels.datamodelFiles import FileItem as FileItemModel
|
||||||
fileRef = fileItem.id
|
mgmtInterface.db.recordModify(FileItemModel, fileItem.id, {
|
||||||
except Exception as e:
|
"scope": "featureInstance",
|
||||||
logger.warning(f"Failed to store document in file DB: {e}")
|
"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:
|
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]:
|
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}
|
return {"read": [], "update": [], "create": [], "noDocumentAction": True}
|
||||||
|
|
||||||
|
|
||||||
def _loadDocumentContents(docIds: List[str], interface) -> List[Dict[str, Any]]:
|
def _getPlatformFileList(mandateId: str = None, instanceId: str = None) -> List[Dict[str, Any]]:
|
||||||
"""Load full extractedText for the given document IDs."""
|
"""Get list of platform FileItems for this feature instance (for doc intent detection)."""
|
||||||
results = []
|
try:
|
||||||
for docId in docIds[:DOC_INTENT_MAX_DOCS]:
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
doc = interface.getDocument(docId)
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
if doc and doc.get("extractedText"):
|
mgmtIf = interfaceDbManagement.getInterface(
|
||||||
results.append({
|
currentUser=None, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
"id": doc.get("id", ""),
|
)
|
||||||
"title": doc.get("summary") or doc.get("fileName", ""),
|
records = mgmtIf.db.getRecordset(
|
||||||
"content": doc.get("extractedText", "")[:DOC_CONTENT_MAX_CHARS],
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -319,20 +360,42 @@ def _resolvePersona(session: Optional[Dict[str, Any]], interface) -> Optional[Di
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _getDocumentSummaries(contextId: str, userId: str, interface) -> Optional[List[str]]:
|
def _getDocumentSummaries(contextId: str, userId: str, interface,
|
||||||
"""Get document summaries for context to include in the AI prompt."""
|
mandateId: str = None, instanceId: str = None) -> Optional[List[str]]:
|
||||||
|
"""Get document summaries from platform FileItems (UDL) for the coaching instance."""
|
||||||
try:
|
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 = []
|
summaries = []
|
||||||
for doc in docs[:5]:
|
for f in files[:10]:
|
||||||
summary = doc.get("summary")
|
fData = f if isinstance(f, dict) else f.model_dump() if hasattr(f, "model_dump") else {}
|
||||||
if summary:
|
name = fData.get("fileName") or fData.get("name") or "Dokument"
|
||||||
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {summary}")
|
fId = fData.get("id")
|
||||||
elif doc.get("extractedText"):
|
snippet = None
|
||||||
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...")
|
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
|
return summaries if summaries else None
|
||||||
except Exception as e:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -427,18 +490,22 @@ class CommcoachService:
|
||||||
)
|
)
|
||||||
|
|
||||||
persona = _resolvePersona(session, interface)
|
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)
|
# Document intent detection (pre-AI-call)
|
||||||
referencedDocumentContents = None
|
referencedDocumentContents = None
|
||||||
allDocs = interface.getDocuments(contextId, self.userId) if documentSummaries else []
|
allDocs = _getPlatformFileList(self.mandateId, self.instanceId) if documentSummaries else []
|
||||||
if allDocs:
|
if allDocs:
|
||||||
await emitSessionEvent(sessionId, "status", {"label": "Dokumente werden geprueft..."})
|
await emitSessionEvent(sessionId, "status", {"label": "Dokumente werden geprueft..."})
|
||||||
docIntent = await _resolveDocumentIntent(combinedUserPrompt, allDocs, self._callAi)
|
docIntent = await _resolveDocumentIntent(combinedUserPrompt, allDocs, self._callAi)
|
||||||
if not docIntent.get("noDocumentAction"):
|
if not docIntent.get("noDocumentAction"):
|
||||||
docIdsToLoad = list(set((docIntent.get("read") or []) + (docIntent.get("update") or [])))
|
docIdsToLoad = list(set((docIntent.get("read") or []) + (docIntent.get("update") or [])))
|
||||||
if docIdsToLoad:
|
if docIdsToLoad:
|
||||||
referencedDocumentContents = _loadDocumentContents(docIdsToLoad, interface)
|
referencedDocumentContents = _loadDocumentContents(
|
||||||
|
docIdsToLoad, interface, mandateId=self.mandateId, instanceId=self.instanceId
|
||||||
|
)
|
||||||
|
|
||||||
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
||||||
context,
|
context,
|
||||||
|
|
@ -536,7 +603,9 @@ class CommcoachService:
|
||||||
|
|
||||||
session = interface.getSession(sessionId)
|
session = interface.getSession(sessionId)
|
||||||
persona = _resolvePersona(session, interface)
|
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(
|
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
||||||
context, previousMessages, tasks,
|
context, previousMessages, tasks,
|
||||||
|
|
|
||||||
|
|
@ -172,20 +172,48 @@ def searchSessionsByTopic(
|
||||||
|
|
||||||
|
|
||||||
def searchSessionsByTopicRag(
|
def searchSessionsByTopicRag(
|
||||||
sessions: List[Dict[str, Any]],
|
|
||||||
query: str,
|
query: str,
|
||||||
maxResults: int = TOPIC_SEARCH_MAX_RESULTS,
|
userId: str,
|
||||||
embeddingProvider: Optional[Any] = None,
|
instanceId: str,
|
||||||
|
mandateId: str = None,
|
||||||
|
queryVector: List[float] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> 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.
|
if not queryVector:
|
||||||
When embeddingProvider is None, falls back to keyword search.
|
logger.warning("searchSessionsByTopicRag called without queryVector, skipping RAG search")
|
||||||
Future: Pass embeddingProvider that has embed(text) -> vector and similarity search.
|
return []
|
||||||
"""
|
try:
|
||||||
if embeddingProvider is None:
|
from modules.interfaces.interfaceDbKnowledge import getInterface as _getKnowledgeInterface
|
||||||
return searchSessionsByTopic(sessions, query, maxResults)
|
|
||||||
# TODO: When embedding API exists: embed query, embed session summaries, cosine similarity
|
knowledgeDb = _getKnowledgeInterface()
|
||||||
return searchSessionsByTopic(sessions, query, maxResults)
|
|
||||||
|
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(
|
def buildSessionSummariesForPrompt(
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,6 @@ class TestCoachingUserProfile:
|
||||||
profile = CoachingUserProfile(
|
profile = CoachingUserProfile(
|
||||||
userId="u1", mandateId="m1", instanceId="i1",
|
userId="u1", mandateId="m1", instanceId="i1",
|
||||||
)
|
)
|
||||||
assert profile.preferredLanguage == "de-DE"
|
|
||||||
assert profile.dailyReminderEnabled is False
|
assert profile.dailyReminderEnabled is False
|
||||||
assert profile.emailSummaryEnabled is True
|
assert profile.emailSummaryEnabled is True
|
||||||
assert profile.streakDays == 0
|
assert profile.streakDays == 0
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,13 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Root user migration failed: {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
|
# After migration: root mandate is purely technical — no feature instances
|
||||||
if not migrationDone and mandateId:
|
if not migrationDone and mandateId:
|
||||||
initRootMandateFeatures(db, mandateId)
|
initRootMandateFeatures(db, mandateId)
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ import logging
|
||||||
from typing import AsyncGenerator, Callable, Dict, Any, Optional, List
|
from typing import AsyncGenerator, Callable, Dict, Any, Optional, List
|
||||||
|
|
||||||
from modules.connectors.connectorVoiceGoogle import ConnectorGoogleSpeech
|
from modules.connectors.connectorVoiceGoogle import ConnectorGoogleSpeech
|
||||||
from modules.datamodels.datamodelVoice import VoiceSettings
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -335,123 +333,6 @@ class VoiceObjects:
|
||||||
"error": str(e)
|
"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
|
# Language and Voice Information
|
||||||
|
|
||||||
async def getAvailableLanguages(self) -> Dict[str, Any]:
|
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.
|
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
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
@ -816,22 +816,19 @@ def getVoicePreferences(
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
|
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
|
||||||
try:
|
rootInterface = getRootInterface()
|
||||||
rootInterface = getRootInterface()
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
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(
|
prefs = rootInterface.db.getRecordset(
|
||||||
UserVoicePreferences,
|
UserVoicePreferences,
|
||||||
recordFilter={"userId": str(currentUser.id), "mandateId": mandateId}
|
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||||
)
|
)
|
||||||
if prefs:
|
if prefs:
|
||||||
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||||
return UserVoicePreferences(userId=str(currentUser.id), mandateId=mandateId).model_dump()
|
return UserVoicePreferences(userId=userId, mandateId=mandateId).model_dump()
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting voice preferences: {e}")
|
|
||||||
return {"sttLanguage": "de-DE", "ttsLanguage": "de-DE"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/voice-preferences")
|
@router.put("/voice-preferences")
|
||||||
|
|
@ -841,34 +838,87 @@ def updateVoicePreferences(
|
||||||
preferences: Dict[str, Any] = Body(...),
|
preferences: Dict[str, Any] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Update user's voice/language preferences."""
|
"""Update user's voice/language preferences (upsert)."""
|
||||||
try:
|
rootInterface = getRootInterface()
|
||||||
rootInterface = getRootInterface()
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
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)
|
userId = str(currentUser.id)
|
||||||
|
|
||||||
existing = rootInterface.db.getRecordset(
|
existing = rootInterface.db.getRecordset(
|
||||||
UserVoicePreferences,
|
UserVoicePreferences,
|
||||||
recordFilter={"userId": userId, "mandateId": mandateId}
|
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||||
)
|
)
|
||||||
|
|
||||||
allowedFields = {
|
allowedFields = {
|
||||||
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
|
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
|
||||||
"translationSourceLanguage", "translationTargetLanguage",
|
"translationSourceLanguage", "translationTargetLanguage",
|
||||||
}
|
}
|
||||||
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
|
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
existingRecord = existing[0]
|
existingRecord = existing[0]
|
||||||
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
|
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
|
||||||
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
|
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
|
||||||
return {"message": "Updated", **updateData}
|
updated = rootInterface.db.getRecordset(UserVoicePreferences, recordFilter={"id": existingId})
|
||||||
else:
|
return updated[0] if updated else {"message": "Updated", **updateData}
|
||||||
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
|
else:
|
||||||
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
|
||||||
return {"message": "Created", **(created if isinstance(created, dict) else created.model_dump())}
|
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
||||||
except Exception as e:
|
return created if isinstance(created, dict) else created.model_dump()
|
||||||
logger.error(f"Error updating voice preferences: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
@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")
|
@router.get("/settings")
|
||||||
async def get_voice_settings(currentUser: User = Depends(getCurrentUser)):
|
async def get_voice_settings(currentUser: User = Depends(getCurrentUser)):
|
||||||
"""Get voice settings for the current user."""
|
"""Get voice settings for the current user (reads from UserVoicePreferences)."""
|
||||||
try:
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
logger.info(f"Getting voice settings for user: {currentUser.id}")
|
from modules.security.rootAccess import getRootInterface
|
||||||
|
rootInterface = getRootInterface()
|
||||||
# Get voice interface
|
userId = str(currentUser.id)
|
||||||
voiceInterface = _getVoiceInterface(currentUser)
|
|
||||||
|
prefs = rootInterface.db.getRecordset(
|
||||||
# Get or create voice settings for the user
|
UserVoicePreferences, recordFilter={"userId": userId}
|
||||||
voice_settings = voiceInterface.getOrCreateVoiceSettings(currentUser.id)
|
)
|
||||||
|
if prefs:
|
||||||
if voice_settings:
|
data = prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||||
# Return user settings
|
return {"success": True, "data": {"user_settings": data}}
|
||||||
return {
|
return {"success": True, "data": {"user_settings": UserVoicePreferences(userId=userId).model_dump()}}
|
||||||
"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")
|
@router.post("/settings")
|
||||||
async def save_voice_settings(
|
async def save_voice_settings(
|
||||||
settings: Dict[str, Any] = Body(...),
|
settings: Dict[str, Any] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(getCurrentUser)
|
||||||
):
|
):
|
||||||
"""Save voice settings for the current user."""
|
"""Save voice settings for the current user (writes to UserVoicePreferences)."""
|
||||||
try:
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
logger.info(f"Saving voice settings for user: {currentUser.id}")
|
from modules.security.rootAccess import getRootInterface
|
||||||
logger.info(f"Settings: {settings}")
|
rootInterface = getRootInterface()
|
||||||
|
userId = str(currentUser.id)
|
||||||
# Validate required settings
|
|
||||||
requiredFields = ["sttLanguage", "ttsLanguage", "ttsVoice"]
|
allowedFields = {
|
||||||
for field in requiredFields:
|
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
|
||||||
if field not in settings:
|
"translationSourceLanguage", "translationTargetLanguage",
|
||||||
raise HTTPException(
|
}
|
||||||
status_code=400,
|
updateData = {k: v for k, v in settings.items() if k in allowedFields}
|
||||||
detail=f"Missing required field: {field}"
|
|
||||||
)
|
existing = rootInterface.db.getRecordset(
|
||||||
|
UserVoicePreferences, recordFilter={"userId": userId}
|
||||||
# Set default values for optional fields if not provided
|
)
|
||||||
if "translationEnabled" not in settings:
|
if existing:
|
||||||
settings["translationEnabled"] = True
|
existingRecord = existing[0]
|
||||||
if "targetLanguage" not in settings:
|
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
|
||||||
settings["targetLanguage"] = "en-US"
|
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
|
||||||
|
else:
|
||||||
# Get voice interface
|
newPrefs = UserVoicePreferences(userId=userId, **updateData)
|
||||||
voiceInterface = _getVoiceInterface(currentUser)
|
rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
||||||
|
|
||||||
# Check if settings already exist for this user
|
return {"success": True, "message": "Voice settings saved successfully", "data": updateData}
|
||||||
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)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# STT Streaming WebSocket — generic, used by all features
|
# STT Streaming WebSocket — generic, used by all features
|
||||||
|
|
|
||||||
|
|
@ -2517,55 +2517,55 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
if not voiceName:
|
if not voiceName:
|
||||||
try:
|
try:
|
||||||
from modules.features.workspace import interfaceFeatureWorkspace
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
featureInstanceId = context.get("featureInstanceId", "")
|
from modules.security.rootAccess import getRootInterface
|
||||||
userId = context.get("userId", "")
|
userId = context.get("userId", "")
|
||||||
if userId:
|
if userId:
|
||||||
wsIf = interfaceFeatureWorkspace.getInterface(
|
rootIf = getRootInterface()
|
||||||
services.user,
|
prefRecords = rootIf.db.getRecordset(
|
||||||
mandateId=mandateId or None,
|
UserVoicePreferences,
|
||||||
featureInstanceId=featureInstanceId or None,
|
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||||
)
|
)
|
||||||
vs = wsIf.getVoiceSettings(userId) if wsIf else None
|
if not prefRecords and mandateId:
|
||||||
if vs:
|
prefRecords = rootIf.db.getRecordset(
|
||||||
voiceMap = {}
|
UserVoicePreferences,
|
||||||
if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap:
|
recordFilter={"userId": userId}
|
||||||
voiceMap = vs.ttsVoiceMap if isinstance(vs.ttsVoiceMap, dict) else {}
|
)
|
||||||
|
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
|
if isinstance(language, str) and language in voiceMap:
|
||||||
selectedVoiceEntry = None
|
selectedKey = language
|
||||||
baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else ""
|
selectedVoiceEntry = voiceMap[language]
|
||||||
|
|
||||||
# 1) Exact match first (e.g. de-DE)
|
if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap:
|
||||||
if isinstance(language, str) and language in voiceMap:
|
selectedKey = baseLanguage
|
||||||
selectedKey = language
|
selectedVoiceEntry = voiceMap[baseLanguage]
|
||||||
selectedVoiceEntry = voiceMap[language]
|
|
||||||
|
|
||||||
# 2) Match short language key (e.g. de)
|
if selectedVoiceEntry is None and baseLanguage:
|
||||||
if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap:
|
for mapKey, mapValue in voiceMap.items():
|
||||||
selectedKey = baseLanguage
|
mapKeyNorm = str(mapKey).lower()
|
||||||
selectedVoiceEntry = voiceMap[baseLanguage]
|
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 not None:
|
||||||
if selectedVoiceEntry is None and baseLanguage:
|
voiceName = (
|
||||||
for mapKey, mapValue in voiceMap.items():
|
selectedVoiceEntry.get("voiceName")
|
||||||
mapKeyNorm = str(mapKey).lower()
|
if isinstance(selectedVoiceEntry, dict)
|
||||||
if mapKeyNorm == baseLanguage or mapKeyNorm.startswith(f"{baseLanguage}-"):
|
else selectedVoiceEntry
|
||||||
selectedKey = str(mapKey)
|
)
|
||||||
selectedVoiceEntry = mapValue
|
logger.info(
|
||||||
break
|
f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')"
|
||||||
|
)
|
||||||
if selectedVoiceEntry is not None:
|
if not voiceName and vs.get("ttsVoice") and vs.get("ttsLanguage") == language:
|
||||||
voiceName = (
|
voiceName = vs["ttsVoice"]
|
||||||
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
|
|
||||||
except Exception as prefErr:
|
except Exception as prefErr:
|
||||||
logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}")
|
logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -557,23 +557,24 @@ detectedIntent-Werte:
|
||||||
|
|
||||||
def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool]:
|
def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool]:
|
||||||
"""Neutralize the prompt text in an AiCallRequest.
|
"""Neutralize the prompt text in an AiCallRequest.
|
||||||
Returns (modifiedRequest, wasNeutralized)."""
|
Returns (modifiedRequest, wasNeutralized).
|
||||||
try:
|
Raises RuntimeError if neutralization is required but fails (fail-safe)."""
|
||||||
neutralSvc = self._get_service("neutralization")
|
neutralSvc = self._get_service("neutralization")
|
||||||
if not neutralSvc or not hasattr(neutralSvc, 'processText'):
|
if not neutralSvc or not hasattr(neutralSvc, 'processText'):
|
||||||
return request, False
|
raise RuntimeError("Neutralization required but neutralization service is unavailable")
|
||||||
|
|
||||||
if request.prompt:
|
if request.prompt:
|
||||||
result = neutralSvc.processText(request.prompt)
|
result = neutralSvc.processText(request.prompt)
|
||||||
if result and result.get("neutralized_text"):
|
if result and result.get("neutralized_text"):
|
||||||
request.prompt = result["neutralized_text"]
|
request.prompt = result["neutralized_text"]
|
||||||
logger.debug("Neutralized prompt in AiCallRequest")
|
logger.debug("Neutralized prompt in AiCallRequest")
|
||||||
return request, True
|
return request, True
|
||||||
|
raise RuntimeError(
|
||||||
|
"Neutralization required but processText returned no neutralized_text — "
|
||||||
|
"AI call blocked to protect sensitive data"
|
||||||
|
)
|
||||||
|
|
||||||
return request, False
|
return request, False
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Request neutralization failed: {e}")
|
|
||||||
return request, False
|
|
||||||
|
|
||||||
def _rehydrateResponse(self, responseText: str) -> str:
|
def _rehydrateResponse(self, responseText: str) -> str:
|
||||||
"""Replace neutralization placeholders with original values in AI response."""
|
"""Replace neutralization placeholders with original values in AI response."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue