tool fixes

This commit is contained in:
ValueOn AG 2026-03-23 00:05:29 +01:00
parent 9ef0d43091
commit 3934cdd3ee
15 changed files with 691 additions and 238 deletions

View file

@ -299,26 +299,65 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
for m in result.get("value", []) for m in result.get("value", [])
] ]
async def sendMail( def _buildMessage(
self, to: List[str], subject: str, body: str, self, to: List[str], subject: str, body: str,
cc: Optional[List[str]] = None, attachments: Optional[List[Dict]] = None bodyType: str = "Text",
cc: Optional[List[str]] = None,
attachments: Optional[List[Dict]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Send an email via Microsoft Graph.""" """Build a Graph API message object.
import json
attachments: list of {"name": str, "contentBytes": str (base64), "contentType": str}
"""
message: Dict[str, Any] = { message: Dict[str, Any] = {
"subject": subject, "subject": subject,
"body": {"contentType": "Text", "content": body}, "body": {"contentType": bodyType, "content": body},
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to], "toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
} }
if cc: if cc:
message["ccRecipients"] = [{"emailAddress": {"address": addr}} for addr in cc] message["ccRecipients"] = [{"emailAddress": {"address": addr}} for addr in cc]
if attachments:
message["attachments"] = [
{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": att["name"],
"contentBytes": att["contentBytes"],
"contentType": att.get("contentType", "application/octet-stream"),
}
for att in attachments
]
return message
async def sendMail(
self, to: List[str], subject: str, body: str,
bodyType: str = "Text",
cc: Optional[List[str]] = None,
attachments: Optional[List[Dict]] = None,
) -> Dict[str, Any]:
"""Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'."""
import json
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8") payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8")
result = await self._graphPost("me/sendMail", payload) result = await self._graphPost("me/sendMail", payload)
if "error" in result: if "error" in result:
return result return result
return {"success": True} return {"success": True}
async def createDraft(
self, to: List[str], subject: str, body: str,
bodyType: str = "Text",
cc: Optional[List[str]] = None,
attachments: Optional[List[Dict]] = None,
) -> Dict[str, Any]:
"""Create a draft email in the user's Drafts folder via Microsoft Graph."""
import json
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
payload = json.dumps(message).encode("utf-8")
result = await self._graphPost("me/messages", payload)
if "error" in result:
return result
return {"success": True, "draft": True, "messageId": result.get("id", "")}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Teams Adapter (Stub) # Teams Adapter (Stub)

View file

@ -1,46 +1,7 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Voice settings datamodel.""" """Voice settings datamodel — re-exported from workspace feature for backward compatibility."""
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid
class VoiceSettings(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsVoiceMap: Dict[str, Any] = Field(default_factory=dict, description="Per-language voice mapping, e.g. {'de-DE': {'voiceName': 'de-DE-Wavenet-A'}, 'en-US': {'voiceName': 'en-US-Wavenet-C'}}", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
translationEnabled: bool = Field(default=True, description="Whether translation is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
targetLanguage: str = Field(default="en-US", description="Target language for translation", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
lastModified: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were last modified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
"VoiceSettings",
{"en": "Voice Settings", "fr": "Paramètres vocaux"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},
"ttsVoiceMap": {"en": "TTS Voice Map", "fr": "Carte des voix TTS"},
"translationEnabled": {"en": "Translation Enabled", "fr": "Traduction activée"},
"targetLanguage": {"en": "Target Language", "fr": "Langue cible"},
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
},
)
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings
__all__ = ["VoiceSettings"]

View file

@ -1192,24 +1192,79 @@ async def deleteDocumentRoute(
def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]: def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]:
"""Extract text from uploaded file content.""" """Extract text from uploaded file content (TXT, MD, HTML, PDF, DOCX, XLSX, PPTX)."""
try:
if mimeType == "text/plain" or fileName.endswith(".txt"):
return content.decode("utf-8", errors="replace")
if mimeType == "text/markdown" or fileName.endswith(".md"):
return content.decode("utf-8", errors="replace")
if "pdf" in mimeType or fileName.endswith(".pdf"):
try:
import io 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 from PyPDF2 import PdfReader
reader = PdfReader(io.BytesIO(content)) reader = PdfReader(io.BytesIO(content))
text = "" return "".join(page.extract_text() or "" for page in reader.pages)
for page in reader.pages:
text += page.extract_text() or ""
return text
except ImportError: except ImportError:
logger.warning("PyPDF2 not installed, cannot extract PDF text") logger.warning("PyPDF2 not installed, cannot extract PDF text")
return None 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: except Exception as e:
logger.warning(f"Text extraction failed for {fileName}: {e}") logger.warning(f"Text extraction failed for {fileName}: {e}")
return None return None

View file

@ -331,7 +331,8 @@ def _getDocumentSummaries(contextId: str, userId: str, interface) -> Optional[Li
elif doc.get("extractedText"): elif doc.get("extractedText"):
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...") summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...")
return summaries if summaries else None return summaries if summaries else None
except Exception: except Exception as e:
logger.warning(f"Failed to load document summaries for context {contextId}: {e}")
return None return None

View file

@ -39,19 +39,21 @@ EXTRACTABLE_BINARY_MIME_TYPES = frozenset({
class NeutralizationService: class NeutralizationService:
"""Service for handling data neutralization operations""" """Service for handling data neutralization operations"""
def __init__(self, serviceCenter=None, NamesToParse: List[str] = None): def __init__(self, serviceCenter=None, getServiceFn=None, NamesToParse: List[str] = None):
"""Initialize the service with user context and anonymization processors """Initialize the service with user context and anonymization processors
Args: Args:
serviceCenter: Service center instance for accessing other services serviceCenter: Service center context or legacy service center instance
getServiceFn: Service resolver function (injected by ServiceCenter resolver)
NamesToParse: List of names to parse and replace (case-insensitive) NamesToParse: List of names to parse and replace (case-insensitive)
""" """
self.services = serviceCenter self.services = serviceCenter
self.interfaceDbComponent = serviceCenter.interfaceDbComponent self._getService = getServiceFn
self.interfaceDbComponent = getattr(serviceCenter, "interfaceDbComponent", None)
# Create feature-specific interface for neutralizer DB operations # Create feature-specific interface for neutralizer DB operations
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
if serviceCenter and serviceCenter.interfaceDbApp: if serviceCenter and getattr(serviceCenter, "interfaceDbApp", None):
dbApp = serviceCenter.interfaceDbApp dbApp = serviceCenter.interfaceDbApp
self.interfaceNeutralizer = getNeutralizerInterface( self.interfaceNeutralizer = getNeutralizerInterface(
currentUser=dbApp.currentUser, currentUser=dbApp.currentUser,
@ -59,10 +61,10 @@ class NeutralizationService:
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None) featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None)
) )
# Initialize anonymization processors namesList = NamesToParse if isinstance(NamesToParse, list) else []
self.NamesToParse = NamesToParse or [] self.NamesToParse = namesList
self.textProcessor = TextProcessor(NamesToParse) self.textProcessor = TextProcessor(namesList)
self.listProcessor = ListProcessor(NamesToParse) self.listProcessor = ListProcessor(namesList)
self.binaryProcessor = BinaryProcessor() self.binaryProcessor = BinaryProcessor()
self.commonUtils = CommonUtils() self.commonUtils = CommonUtils()

View file

@ -0,0 +1,65 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Workspace feature data models — VoiceSettings and WorkspaceUserSettings."""
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid
class VoiceSettings(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsVoiceMap: Dict[str, Any] = Field(default_factory=dict, description="Per-language voice mapping, e.g. {'de-DE': {'voiceName': 'de-DE-Wavenet-A'}, 'en-US': {'voiceName': 'en-US-Wavenet-C'}}", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
translationEnabled: bool = Field(default=True, description="Whether translation is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
targetLanguage: str = Field(default="en-US", description="Target language for translation", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
lastModified: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were last modified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
class WorkspaceUserSettings(BaseModel):
"""Per-user workspace settings. None values mean 'use instance default'."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="Feature Instance ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
registerModelLabels(
"VoiceSettings",
{"en": "Voice Settings", "fr": "Paramètres vocaux"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},
"ttsVoiceMap": {"en": "TTS Voice Map", "fr": "Carte des voix TTS"},
"translationEnabled": {"en": "Translation Enabled", "fr": "Traduction activée"},
"targetLanguage": {"en": "Target Language", "fr": "Langue cible"},
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
},
)
registerModelLabels(
"WorkspaceUserSettings",
{"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"},
{
"id": {"en": "ID", "de": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"},
"maxAgentRounds": {"en": "Max Agent Rounds", "de": "Max. Agenten-Runden"},
},
)

View file

@ -0,0 +1,248 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Interface for Workspace feature manages VoiceSettings and WorkspaceUserSettings.
Uses a dedicated poweron_workspace database.
"""
import logging
from typing import Dict, Any, Optional
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelUam import User
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings, WorkspaceUserSettings
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.security.rbac import RbacClass
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
_workspaceInterfaces: Dict[str, "WorkspaceObjects"] = {}
class WorkspaceObjects:
"""Interface for Workspace-specific database operations (voice + general settings)."""
def __init__(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.userId = currentUser.id if currentUser else None
self._initializeDatabase()
from modules.security.rootAccess import getRootDbAppConnector
dbApp = getRootDbAppConnector()
self.rbac = RbacClass(self.db, dbApp=dbApp)
self.db.updateContext(self.userId)
def _initializeDatabase(self):
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_workspace"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=self.userId,
)
logger.debug(f"Workspace database initialized for user {self.userId}")
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.db.updateContext(self.userId)
# =========================================================================
# VoiceSettings CRUD
# =========================================================================
def getVoiceSettings(self, userId: Optional[str] = None) -> Optional[VoiceSettings]:
try:
targetUserId = userId or self.userId
if not targetUserId:
logger.error("No user ID provided for voice settings")
return None
recordFilter: Dict[str, Any] = {"userId": targetUserId}
if self.featureInstanceId:
recordFilter["featureInstanceId"] = self.featureInstanceId
filteredSettings = getRecordsetWithRBAC(
self.db, VoiceSettings, self.currentUser,
recordFilter=recordFilter, mandateId=self.mandateId,
)
if not filteredSettings:
return None
settingsData = filteredSettings[0]
if not settingsData.get("creationDate"):
settingsData["creationDate"] = getUtcTimestamp()
if not settingsData.get("lastModified"):
settingsData["lastModified"] = getUtcTimestamp()
return VoiceSettings(**settingsData)
except Exception as e:
logger.error(f"Error getting voice settings: {e}")
return None
def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
try:
if "userId" not in settingsData:
settingsData["userId"] = self.userId
if "mandateId" not in settingsData:
settingsData["mandateId"] = self.mandateId
if "featureInstanceId" not in settingsData:
settingsData["featureInstanceId"] = self.featureInstanceId
existing = self.getVoiceSettings(settingsData["userId"])
if existing:
raise ValueError(f"Voice settings already exist for user {settingsData['userId']}")
createdRecord = self.db.recordCreate(VoiceSettings, settingsData)
if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create voice settings record")
logger.info(f"Created voice settings for user {settingsData['userId']}")
return createdRecord
except Exception as e:
logger.error(f"Error creating voice settings: {e}")
raise
def updateVoiceSettings(self, userId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
try:
existing = self.getVoiceSettings(userId)
if not existing:
raise ValueError(f"Voice settings not found for user {userId}")
updateData["lastModified"] = getUtcTimestamp()
success = self.db.recordModify(VoiceSettings, existing.id, updateData)
if not success:
raise ValueError("Failed to update voice settings record")
updated = self.getVoiceSettings(userId)
if not updated:
raise ValueError("Failed to retrieve updated voice settings")
logger.info(f"Updated voice settings for user {userId}")
return updated.model_dump()
except Exception as e:
logger.error(f"Error updating voice settings: {e}")
raise
def deleteVoiceSettings(self, userId: str) -> bool:
try:
existing = self.getVoiceSettings(userId)
if not existing:
return False
success = self.db.recordDelete(VoiceSettings, existing.id)
if success:
logger.info(f"Deleted voice settings for user {userId}")
return success
except Exception as e:
logger.error(f"Error deleting voice settings: {e}")
return False
def getOrCreateVoiceSettings(self, userId: Optional[str] = None) -> VoiceSettings:
targetUserId = userId or self.userId
if not targetUserId:
raise ValueError("No user ID provided for voice settings")
existing = self.getVoiceSettings(targetUserId)
if existing:
return existing
defaultSettings = {
"userId": targetUserId,
"mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-KatjaNeural",
"translationEnabled": True,
"targetLanguage": "en-US",
}
createdRecord = self.createVoiceSettings(defaultSettings)
return VoiceSettings(**createdRecord)
# =========================================================================
# WorkspaceUserSettings CRUD
# =========================================================================
def getWorkspaceUserSettings(self, userId: Optional[str] = None) -> Optional[WorkspaceUserSettings]:
targetUserId = userId or self.userId
if not targetUserId:
return None
try:
recordFilter: Dict[str, Any] = {"userId": targetUserId}
if self.featureInstanceId:
recordFilter["featureInstanceId"] = self.featureInstanceId
records = getRecordsetWithRBAC(
self.db, WorkspaceUserSettings, self.currentUser,
recordFilter=recordFilter, mandateId=self.mandateId,
)
if not records:
return None
return WorkspaceUserSettings(**records[0])
except Exception as e:
logger.error(f"Error getting workspace user settings: {e}")
return None
def saveWorkspaceUserSettings(self, data: Dict[str, Any]) -> WorkspaceUserSettings:
"""Upsert: create or update workspace user settings for the current user."""
targetUserId = data.get("userId", self.userId)
if not targetUserId:
raise ValueError("No user ID provided")
existing = self.getWorkspaceUserSettings(targetUserId)
if existing:
updateData = {k: v for k, v in data.items() if k not in ("id", "userId", "mandateId", "featureInstanceId")}
self.db.recordModify(WorkspaceUserSettings, existing.id, updateData)
updated = self.getWorkspaceUserSettings(targetUserId)
if not updated:
raise ValueError("Failed to retrieve updated workspace user settings")
return updated
createData = {
"userId": targetUserId,
"mandateId": data.get("mandateId", self.mandateId),
"featureInstanceId": data.get("featureInstanceId", self.featureInstanceId),
}
createData.update({k: v for k, v in data.items() if k not in ("id", "userId", "mandateId", "featureInstanceId")})
createdRecord = self.db.recordCreate(WorkspaceUserSettings, createData)
if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create workspace user settings")
return WorkspaceUserSettings(**createdRecord)
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> WorkspaceObjects:
if not currentUser:
raise ValueError("Invalid user context: user is required")
effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
contextKey = f"workspace_{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}"
if contextKey not in _workspaceInterfaces:
_workspaceInterfaces[contextKey] = WorkspaceObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
_workspaceInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _workspaceInterfaces[contextKey]

View file

@ -23,6 +23,7 @@ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription
SubscriptionInactiveException, SubscriptionInactiveException,
) )
from modules.interfaces import interfaceDbChat, interfaceDbManagement from modules.interfaces import interfaceDbChat, interfaceDbManagement
from modules.features.workspace import interfaceFeatureWorkspace
from modules.interfaces.interfaceAiObjects import AiObjects from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.core.serviceStreaming import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
@ -145,6 +146,14 @@ def _getDbManagement(context: RequestContext, featureInstanceId: str = None):
) )
def _getWorkspaceInterface(context: RequestContext, featureInstanceId: str = None):
return interfaceFeatureWorkspace.getInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=featureInstanceId,
)
_SOURCE_TYPE_TO_SERVICE = { _SOURCE_TYPE_TO_SERVICE = {
"sharepointFolder": "sharepoint", "sharepointFolder": "sharepoint",
"onedriveFolder": "onedrive", "onedriveFolder": "onedrive",
@ -701,7 +710,17 @@ async def _runWorkspaceAgent(
_toolSet = _cfg.get("toolSet", "core") _toolSet = _cfg.get("toolSet", "core")
_agentCfg = _cfg.get("agentConfig") _agentCfg = _cfg.get("agentConfig")
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentConfig from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentConfig
agentConfig = AgentConfig(**_agentCfg) if isinstance(_agentCfg, dict) else None
agentCfgDict = dict(_agentCfg) if isinstance(_agentCfg, dict) else {}
try:
wsIf = interfaceFeatureWorkspace.getInterface(user, mandateId=mandateId, featureInstanceId=instanceId)
userSettings = wsIf.getWorkspaceUserSettings(user.id if user else None)
if userSettings and userSettings.maxAgentRounds is not None:
agentCfgDict["maxRounds"] = userSettings.maxAgentRounds
except Exception as e:
logger.debug(f"Could not load workspace user settings for agent config: {e}")
agentConfig = AgentConfig(**agentCfgDict) if agentCfgDict else None
async for event in agentService.runAgent( async for event in agentService.runAgent(
prompt=enrichedPrompt, prompt=enrichedPrompt,
@ -1575,13 +1594,13 @@ async def getVoiceSettings(
): ):
"""Load voice settings for the current user and instance.""" """Load voice settings for the current user and instance."""
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
dbMgmt = _getDbManagement(context, instanceId) wsInterface = _getWorkspaceInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
try: try:
vs = dbMgmt.getVoiceSettings(userId) vs = wsInterface.getVoiceSettings(userId)
if not vs: if not vs:
logger.info(f"GET voice settings: not found for user={userId}, creating defaults") logger.info(f"GET voice settings: not found for user={userId}, creating defaults")
vs = dbMgmt.getOrCreateVoiceSettings(userId) vs = wsInterface.getOrCreateVoiceSettings(userId)
result = vs.model_dump() if vs else {} result = vs.model_dump() if vs else {}
mapKeys = list(result.get("ttsVoiceMap", {}).keys()) if result else [] mapKeys = list(result.get("ttsVoiceMap", {}).keys()) if result else []
logger.info(f"GET voice settings for user={userId}: ttsVoiceMap languages={mapKeys}") logger.info(f"GET voice settings for user={userId}: ttsVoiceMap languages={mapKeys}")
@ -1601,12 +1620,12 @@ async def updateVoiceSettings(
): ):
"""Update voice settings for the current user and instance.""" """Update voice settings for the current user and instance."""
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
dbMgmt = _getDbManagement(context, instanceId) wsInterface = _getWorkspaceInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)
try: try:
logger.info(f"PUT voice settings for user={userId}, instance={instanceId}, body keys={list(body.keys())}") logger.info(f"PUT voice settings for user={userId}, instance={instanceId}, body keys={list(body.keys())}")
vs = dbMgmt.getVoiceSettings(userId) vs = wsInterface.getVoiceSettings(userId)
if not vs: if not vs:
logger.info(f"No existing voice settings, creating new for user={userId}") logger.info(f"No existing voice settings, creating new for user={userId}")
createData = { createData = {
@ -1615,13 +1634,13 @@ async def updateVoiceSettings(
"featureInstanceId": instanceId, "featureInstanceId": instanceId,
} }
createData.update(body) createData.update(body)
created = dbMgmt.createVoiceSettings(createData) created = wsInterface.createVoiceSettings(createData)
logger.info(f"Created voice settings for user={userId}, ttsVoiceMap keys={list((created or {}).get('ttsVoiceMap', {}).keys())}") logger.info(f"Created voice settings for user={userId}, ttsVoiceMap keys={list((created or {}).get('ttsVoiceMap', {}).keys())}")
return JSONResponse(created) return JSONResponse(created)
updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")} updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")}
logger.info(f"Updating voice settings for user={userId}, update keys={list(updateData.keys())}") logger.info(f"Updating voice settings for user={userId}, update keys={list(updateData.keys())}")
updated = dbMgmt.updateVoiceSettings(userId, updateData) updated = wsInterface.updateVoiceSettings(userId, updateData)
logger.info(f"Updated voice settings for user={userId}, ttsVoiceMap keys={list((updated or {}).get('ttsVoiceMap', {}).keys())}") logger.info(f"Updated voice settings for user={userId}, ttsVoiceMap keys={list((updated or {}).get('ttsVoiceMap', {}).keys())}")
return JSONResponse(updated) return JSONResponse(updated)
except Exception as e: except Exception as e:
@ -1827,3 +1846,63 @@ async def rejectAllEdits(
logger.info(f"Rejected {len(rejected)} edits for instance {instanceId}") logger.info(f"Rejected {len(rejected)} edits for instance {instanceId}")
return JSONResponse({"rejected": rejected}) return JSONResponse({"rejected": rejected})
# =========================================================================
# General Settings Endpoints (per-user workspace settings)
# =========================================================================
@router.get("/{instanceId}/settings/general")
@limiter.limit("120/minute")
async def getGeneralSettings(
request: Request,
instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
"""Load general workspace settings for the current user, with effective values."""
_mandateId, instanceConfig = _validateInstanceAccess(instanceId, context)
wsInterface = _getWorkspaceInterface(context, instanceId)
userId = str(context.user.id)
userSettings = wsInterface.getWorkspaceUserSettings(userId)
agentCfg = (instanceConfig or {}).get("agentConfig", {})
instanceDefault = agentCfg.get("maxRounds", 25) if isinstance(agentCfg, dict) else 25
userOverride = userSettings.maxAgentRounds if userSettings else None
effective = userOverride if userOverride is not None else instanceDefault
return JSONResponse({
"maxAgentRounds": {
"effective": effective,
"userOverride": userOverride,
"instanceDefault": instanceDefault,
},
})
@router.put("/{instanceId}/settings/general")
@limiter.limit("120/minute")
async def updateGeneralSettings(
request: Request,
instanceId: str = Path(...),
body: dict = Body(...),
context: RequestContext = Depends(getRequestContext),
):
"""Update general workspace settings for the current user."""
_validateInstanceAccess(instanceId, context)
wsInterface = _getWorkspaceInterface(context, instanceId)
userId = str(context.user.id)
data = {
"userId": userId,
"mandateId": str(context.mandateId) if context.mandateId else "",
"featureInstanceId": instanceId,
}
if "maxAgentRounds" in body:
val = body["maxAgentRounds"]
data["maxAgentRounds"] = int(val) if val is not None else None
wsInterface.saveWorkspaceUserSettings(data)
return await getGeneralSettings(request, instanceId, context)

View file

@ -7,6 +7,8 @@ and semantic search via pgvector.
""" """
import logging import logging
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.connectors.connectorDbPostgre import _get_cached_connector from modules.connectors.connectorDbPostgre import _get_cached_connector

View file

@ -21,7 +21,6 @@ from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelFiles import FilePreview, FileItem, FileData from modules.datamodels.datamodelFiles import FilePreview, FileItem, FileData
from modules.datamodels.datamodelFileFolder import FileFolder from modules.datamodels.datamodelFileFolder import FileFolder
from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelUtils import Prompt
from modules.datamodels.datamodelVoice import VoiceSettings
from modules.datamodels.datamodelMessaging import ( from modules.datamodels.datamodelMessaging import (
MessagingSubscription, MessagingSubscription,
MessagingSubscriptionRegistration, MessagingSubscriptionRegistration,
@ -1723,158 +1722,6 @@ class ComponentObjects:
logger.error(f"Error in saveUploadedFile for {fileName}: {str(e)}", exc_info=True) logger.error(f"Error in saveUploadedFile for {fileName}: {str(e)}", exc_info=True)
raise FileStorageError(f"Error saving file: {str(e)}") raise FileStorageError(f"Error saving file: {str(e)}")
# VoiceSettings methods
def getVoiceSettings(self, userId: Optional[str] = None) -> Optional[VoiceSettings]:
"""Returns voice settings for a user if user has access."""
try:
targetUserId = userId or self.userId
if not targetUserId:
logger.error("No user ID provided for voice settings")
return None
recordFilter: Dict[str, Any] = {"userId": targetUserId}
if self.featureInstanceId:
recordFilter["featureInstanceId"] = self.featureInstanceId
# Get voice settings for the user (scoped to current feature instance if available), filtered by RBAC
filteredSettings = getRecordsetWithRBAC(self.db,
VoiceSettings,
self.currentUser,
recordFilter=recordFilter,
mandateId=self.mandateId
)
if not filteredSettings:
logger.warning(f"No access to voice settings for user {targetUserId}")
return None
# Ensure timestamps are set for validation
settings_data = filteredSettings[0]
if not settings_data.get("creationDate"):
settings_data["creationDate"] = getUtcTimestamp()
if not settings_data.get("lastModified"):
settings_data["lastModified"] = getUtcTimestamp()
return VoiceSettings(**settings_data)
except Exception as e:
logger.error(f"Error getting voice settings: {str(e)}")
return None
def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
"""Creates voice settings for a user if user has permission."""
try:
if not self.checkRbacPermission(VoiceSettings, "update"):
raise PermissionError("No permission to create voice settings")
# Ensure userId is set
if "userId" not in settingsData:
settingsData["userId"] = self.userId
# Ensure mandateId and featureInstanceId are set from context
if "mandateId" not in settingsData:
settingsData["mandateId"] = self.mandateId
if "featureInstanceId" not in settingsData:
settingsData["featureInstanceId"] = self.featureInstanceId
# Check if settings already exist for this user
existingSettings = self.getVoiceSettings(settingsData["userId"])
if existingSettings:
raise ValueError(f"Voice settings already exist for user {settingsData['userId']}")
# Create voice settings record
createdRecord = self.db.recordCreate(VoiceSettings, settingsData)
if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create voice settings record")
logger.info(f"Created voice settings for user {settingsData['userId']}")
return createdRecord
except Exception as e:
logger.error(f"Error creating voice settings: {str(e)}")
raise
def updateVoiceSettings(self, userId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
"""Updates voice settings for a user if user has access."""
try:
# Get existing settings
existingSettings = self.getVoiceSettings(userId)
if not existingSettings:
raise ValueError(f"Voice settings not found for user {userId}")
# Update lastModified timestamp
updateData["lastModified"] = getUtcTimestamp()
# Update voice settings record
success = self.db.recordModify(VoiceSettings, existingSettings.id, updateData)
if not success:
raise ValueError("Failed to update voice settings record")
# Get updated settings
updatedSettings = self.getVoiceSettings(userId)
if not updatedSettings:
raise ValueError("Failed to retrieve updated voice settings")
logger.info(f"Updated voice settings for user {userId}")
return updatedSettings.model_dump()
except Exception as e:
logger.error(f"Error updating voice settings: {str(e)}")
raise
def deleteVoiceSettings(self, userId: str) -> bool:
"""Deletes voice settings for a user if user has access."""
try:
# Get existing settings
existingSettings = self.getVoiceSettings(userId)
if not existingSettings:
logger.warning(f"Voice settings not found for user {userId}")
return False
# Delete voice settings
success = self.db.recordDelete(VoiceSettings, existingSettings.id)
if success:
logger.info(f"Deleted voice settings for user {userId}")
else:
logger.error(f"Failed to delete voice settings for user {userId}")
return success
except Exception as e:
logger.error(f"Error deleting voice settings: {str(e)}")
return False
def getOrCreateVoiceSettings(self, userId: Optional[str] = None) -> VoiceSettings:
"""Gets existing voice settings or creates default ones for a user."""
try:
targetUserId = userId or self.userId
if not targetUserId:
raise ValueError("No user ID provided for voice settings")
# Try to get existing settings
existingSettings = self.getVoiceSettings(targetUserId)
if existingSettings:
return existingSettings
# Create default settings
defaultSettings = {
"userId": targetUserId,
"mandateId": self.mandateId,
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-KatjaNeural",
"translationEnabled": True,
"targetLanguage": "en-US"
}
createdRecord = self.createVoiceSettings(defaultSettings)
return VoiceSettings(**createdRecord)
except Exception as e:
logger.error(f"Error getting or creating voice settings: {str(e)}")
raise
# Messaging Subscription methods # Messaging Subscription methods
def getAllSubscriptions(self, pagination: Optional[PaginationParams] = None) -> Union[List[MessagingSubscription], PaginatedResult]: def getAllSubscriptions(self, pagination: Optional[PaginationParams] = None) -> Union[List[MessagingSubscription], PaginatedResult]:

View file

@ -99,7 +99,7 @@ IMPORTABLE_SERVICES: Dict[str, Dict[str, Any]] = {
"label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche Web"}, "label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche Web"},
}, },
"neutralization": { "neutralization": {
"module": "modules.serviceCenter.services.serviceNeutralization.mainServiceNeutralization", "module": "modules.features.neutralization.serviceNeutralization.mainServiceNeutralization",
"class": "NeutralizationService", "class": "NeutralizationService",
"dependencies": ["extraction", "generation"], "dependencies": ["extraction", "generation"],
"objectKey": "service.neutralization", "objectKey": "service.neutralization",

View file

@ -1432,21 +1432,55 @@ def _registerCoreTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e)) return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e))
async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]): async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]):
import base64 as _b64
connectionId = args.get("connectionId", "") connectionId = args.get("connectionId", "")
to = args.get("to", []) to = args.get("to", [])
subject = args.get("subject", "") subject = args.get("subject", "")
body = args.get("body", "") body = args.get("body", "")
bodyType = "HTML" if args.get("bodyType", "text").lower() == "html" else "Text"
draft = args.get("draft", False)
attachmentFileIds = args.get("attachmentFileIds") or []
if not connectionId or not to or not subject: if not connectionId or not to or not subject:
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required") return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required")
try: try:
graphAttachments: List[Dict[str, Any]] = []
if attachmentFileIds:
chatService = services.chat
dbMgmt = chatService.interfaceDbComponent
for fid in attachmentFileIds:
fileRow = dbMgmt.getFile(fid)
if not fileRow:
return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file not found: {fid}")
rawBytes = dbMgmt.getFileData(fid)
if not rawBytes:
return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file has no data: {fid}")
graphAttachments.append({
"name": fileRow.fileName,
"contentBytes": _b64.b64encode(rawBytes).decode("ascii"),
"contentType": getattr(fileRow, "mimeType", "application/octet-stream"),
})
from modules.connectors.connectorResolver import ConnectorResolver from modules.connectors.connectorResolver import ConnectorResolver
resolver = ConnectorResolver( resolver = ConnectorResolver(
services.getService("security"), services.getService("security"),
_buildResolverDb(), _buildResolverDb(),
) )
adapter = await resolver.resolveService(connectionId, "outlook") adapter = await resolver.resolveService(connectionId, "outlook")
if draft and hasattr(adapter, "createDraft"):
result = await adapter.createDraft(
to=to, subject=subject, body=body, bodyType=bodyType,
cc=args.get("cc"), attachments=graphAttachments or None,
)
return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result))
if hasattr(adapter, "sendMail"): if hasattr(adapter, "sendMail"):
result = await adapter.sendMail(to=to, subject=subject, body=body, cc=args.get("cc")) result = await adapter.sendMail(
to=to, subject=subject, body=body, bodyType=bodyType,
cc=args.get("cc"), attachments=graphAttachments or None,
)
return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result)) return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result))
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="Mail not supported by this adapter") return ToolResult(toolCallId="", toolName="sendMail", success=False, error="Mail not supported by this adapter")
except Exception as e: except Exception as e:
@ -1484,15 +1518,26 @@ def _registerCoreTools(registry: ToolRegistry, services):
registry.register( registry.register(
"sendMail", _sendMail, "sendMail", _sendMail,
description="Send an email via a connected mail service (Outlook, Gmail). Use listConnections to find the connectionId.", description=(
"Send or draft an email via a connected mail service (Outlook). "
"Supports HTML body and file attachments from the workspace. "
"Set draft=true to save as draft without sending. "
"Use listConnections to find the connectionId."
),
parameters={ parameters={
"type": "object", "type": "object",
"properties": { "properties": {
"connectionId": {"type": "string", "description": "UserConnection ID"}, "connectionId": {"type": "string", "description": "UserConnection ID"},
"to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"}, "to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"},
"subject": {"type": "string", "description": "Email subject"}, "subject": {"type": "string", "description": "Email subject"},
"body": {"type": "string", "description": "Email body text"}, "body": {"type": "string", "description": "Email body — plain text or HTML markup"},
"bodyType": {"type": "string", "enum": ["text", "html"], "description": "Body format: 'text' (default) or 'html'"},
"cc": {"type": "array", "items": {"type": "string"}, "description": "CC addresses"}, "cc": {"type": "array", "items": {"type": "string"}, "description": "CC addresses"},
"attachmentFileIds": {
"type": "array", "items": {"type": "string"},
"description": "File IDs from the workspace to attach (use listFiles to find IDs)",
},
"draft": {"type": "boolean", "description": "If true, save as draft in Drafts folder instead of sending"},
}, },
"required": ["connectionId", "to", "subject", "body"], "required": ["connectionId", "to", "subject", "body"],
}, },
@ -2471,16 +2516,16 @@ def _registerCoreTools(registry: ToolRegistry, services):
if not voiceName: if not voiceName:
try: try:
from modules.interfaces import interfaceDbManagement from modules.features.workspace import interfaceFeatureWorkspace
featureInstanceId = context.get("featureInstanceId", "") featureInstanceId = context.get("featureInstanceId", "")
userId = context.get("userId", "") userId = context.get("userId", "")
if userId: if userId:
dbMgmt = interfaceDbManagement.getInterface( wsIf = interfaceFeatureWorkspace.getInterface(
services.user, services.user,
mandateId=mandateId or None, mandateId=mandateId or None,
featureInstanceId=featureInstanceId or None, featureInstanceId=featureInstanceId or None,
) )
vs = dbMgmt.getVoiceSettings(userId) if dbMgmt and hasattr(dbMgmt, "getVoiceSettings") else None vs = wsIf.getVoiceSettings(userId) if wsIf else None
if vs: if vs:
voiceMap = {} voiceMap = {}
if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap: if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap:
@ -2914,6 +2959,8 @@ def _registerCoreTools(registry: ToolRegistry, services):
neutralizationService = services.getService("neutralization") neutralizationService = services.getService("neutralization")
if not neutralizationService: if not neutralizationService:
return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available") return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available")
if not neutralizationService.interfaceDbComponent:
neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
if text: if text:
result = neutralizationService.processText(text) result = neutralizationService.processText(text)
else: else:

View file

@ -29,7 +29,7 @@ from modules.interfaces.interfaceDbBilling import getInterface as getBillingInte
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Markup percentage for internal pricing (+50% für Infrastruktur und Platform Service + 50% für Währungsrisiko ==> Faktor 2.0) # Markup percentage for internal pricing (+50% für Infrastruktur und Platform Service + 50% für Währungsrisiko ==> Faktor 2.0)
BILLING_MARKUP_PERCENT = 100 BILLING_MARKUP_PERCENT = 400
# Singleton cache # Singleton cache
_billingServices: Dict[str, "BillingService"] = {} _billingServices: Dict[str, "BillingService"] = {}

View file

@ -374,11 +374,13 @@ class SubscriptionService:
raise ValueError("Subscription is already cancelled (non-recurring)") raise ValueError("Subscription is already cancelled (non-recurring)")
stripeSubId = sub.get("stripeSubscriptionId") stripeSubId = sub.get("stripeSubscriptionId")
pUrl = ""
if stripeSubId: if stripeSubId:
try: try:
from modules.shared.stripeClient import getStripeClient from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient() stripe = getStripeClient()
stripe.Subscription.modify(stripeSubId, cancel_at_period_end=True) stripeSub = stripe.Subscription.modify(stripeSubId, cancel_at_period_end=True)
pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "")
except Exception as e: except Exception as e:
logger.error("Failed to set cancel_at_period_end for %s: %s", stripeSubId, e) logger.error("Failed to set cancel_at_period_end for %s: %s", stripeSubId, e)
@ -386,7 +388,7 @@ class SubscriptionService:
self.invalidateCache(mandateId) self.invalidateCache(mandateId)
plan = _getPlan(sub.get("planKey", "")) plan = _getPlan(sub.get("planKey", ""))
_notifySubscriptionChange(mandateId, "cancelled", plan) _notifySubscriptionChange(mandateId, "cancelled", plan, subscriptionRecord=sub, platformUrl=pUrl)
return result return result
# ========================================================================= # =========================================================================
@ -435,16 +437,23 @@ class SubscriptionService:
raise ValueError(f"Subscription {subscriptionId} not found") raise ValueError(f"Subscription {subscriptionId} not found")
stripeSubId = sub.get("stripeSubscriptionId") stripeSubId = sub.get("stripeSubscriptionId")
pUrl = ""
if stripeSubId: if stripeSubId:
try: try:
from modules.shared.stripeClient import getStripeClient from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient() stripe = getStripeClient()
stripeSub = stripe.Subscription.retrieve(stripeSubId)
pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "")
stripe.Subscription.cancel(stripeSubId) stripe.Subscription.cancel(stripeSubId)
except Exception as e: except Exception as e:
logger.error("Failed to cancel Stripe sub %s: %s", stripeSubId, e) logger.error("Failed to cancel Stripe sub %s: %s", stripeSubId, e)
result = self._interface.forceExpire(subscriptionId) result = self._interface.forceExpire(subscriptionId)
self.invalidateCache(sub["mandateId"]) mandateId = sub["mandateId"]
self.invalidateCache(mandateId)
plan = _getPlan(sub.get("planKey", ""))
_notifySubscriptionChange(mandateId, "force_cancelled", plan, subscriptionRecord=sub, platformUrl=pUrl)
return result return result
# ========================================================================= # =========================================================================
@ -496,6 +505,8 @@ def _notifySubscriptionChange(
if event == "activated" and plan and subscriptionRecord: if event == "activated" and plan and subscriptionRecord:
rawHtmlBlock = _buildInvoiceSummaryHtml(plan, subscriptionRecord, mandateId, platformUrl) rawHtmlBlock = _buildInvoiceSummaryHtml(plan, subscriptionRecord, mandateId, platformUrl)
elif event in ("cancelled", "force_cancelled") and subscriptionRecord:
rawHtmlBlock = _buildCancelSummaryHtml(subscriptionRecord, platformUrl)
templates: Dict[str, Dict[str, Any]] = { templates: Dict[str, Dict[str, Any]] = {
"activated": { "activated": {
@ -520,6 +531,17 @@ def _notifySubscriptionChange(
] if p ] if p
], ],
}, },
"force_cancelled": {
"subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}",
"headline": "Abonnement sofort beendet",
"paragraphs": [
p for p in [
f"Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.",
platformHint,
"Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support.",
] if p
],
},
"trial_expired": { "trial_expired": {
"subject": "[PowerOn] Testphase abgelaufen", "subject": "[PowerOn] Testphase abgelaufen",
"headline": "Testphase abgelaufen", "headline": "Testphase abgelaufen",
@ -633,6 +655,32 @@ def _buildInvoiceSummaryHtml(
) )
def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") -> str:
"""Build an HTML block with billing link and Stripe invoice link for cancel emails."""
import html as htmlmod
parts: list[str] = []
stripeSubId = subRecord.get("stripeSubscriptionId")
if stripeSubId:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
invoices = stripe.Invoice.list(subscription=stripeSubId, limit=1)
if invoices.data:
hostedUrl = invoices.data[0].get("hosted_invoice_url", "")
if hostedUrl:
parts.append(
f'<p style="margin:4px 0;font-size:14px;">'
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
f'Letzte Stripe-Rechnung anzeigen</a></p>'
)
except Exception as e:
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
return "\n".join(parts) if parts else ""
# ============================================================================ # ============================================================================
# Exception Classes # Exception Classes
# ============================================================================ # ============================================================================

View file

@ -105,6 +105,43 @@ def _findExistingStripePrice(stripe, productId: str, unitAmount: int, interval:
return None return None
def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
"""Retrieve the unit_amount (in Rappen) of an existing Stripe Price."""
try:
price = stripe.Price.retrieve(priceId)
return price.get("unit_amount") if price else None
except Exception:
return None
def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float, interval: str, nickname: str) -> str:
"""If the stored Stripe Price has a different amount, create a new one and deactivate the old."""
expectedCents = int(expectedCHF * 100)
actualCents = _getStripePriceAmount(stripe, oldPriceId)
if actualCents == expectedCents:
return oldPriceId
logger.warning(
"Price drift detected for %s: Stripe has %s Rappen, catalog expects %s Rappen. Rotating price.",
oldPriceId, actualCents, expectedCents,
)
existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval)
if existingMatch:
newPriceId = existingMatch
else:
newPriceId = _createStripePrice(stripe, productId, expectedCHF, interval, nickname)
try:
stripe.Price.modify(oldPriceId, active=False)
logger.info("Deactivated old Stripe Price %s", oldPriceId)
except Exception as e:
logger.warning("Could not deactivate old price %s: %s", oldPriceId, e)
return newPriceId
def _createStripePrice(stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str) -> str: def _createStripePrice(stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str) -> str:
price = stripe.Price.create( price = stripe.Price.create(
product=productId, product=productId,
@ -146,7 +183,29 @@ def bootstrapStripePrices() -> None:
hasAllPrices = mapping.stripePriceIdUsers and mapping.stripePriceIdInstances hasAllPrices = mapping.stripePriceIdUsers and mapping.stripePriceIdInstances
hasAllProducts = mapping.stripeProductIdUsers and mapping.stripeProductIdInstances hasAllProducts = mapping.stripeProductIdUsers and mapping.stripeProductIdInstances
if hasAllPrices and hasAllProducts: if hasAllPrices and hasAllProducts:
logger.debug("Stripe prices already configured for plan %s", planKey) changed = False
reconciledUsers = _reconcilePrice(
stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
)
if reconciledUsers != mapping.stripePriceIdUsers:
changed = True
reconciledInstances = _reconcilePrice(
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
)
if reconciledInstances != mapping.stripePriceIdInstances:
changed = True
if changed:
db.recordModify(StripePlanPrice, mapping.id, {
"stripePriceIdUsers": reconciledUsers,
"stripePriceIdInstances": reconciledInstances,
})
logger.info("Reconciled Stripe prices for plan %s: users=%s, instances=%s", planKey, reconciledUsers, reconciledInstances)
else:
logger.debug("Stripe prices up-to-date for plan %s", planKey)
continue continue
productIdUsers = None productIdUsers = None