unified data - step 1

This commit is contained in:
ValueOn AG 2026-03-24 14:16:46 +01:00
parent f796ae3807
commit 8b161ed410
27 changed files with 1764 additions and 416 deletions

View file

@ -30,6 +30,21 @@ class DataSource(BaseModel):
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
@ -48,6 +63,8 @@ registerModelLabels(
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
)

View file

@ -24,6 +24,21 @@ class FileItem(BaseModel):
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
)
neutralize: bool = Field(
default=False,
description="Whether this file should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"FileItem",
@ -41,6 +56,8 @@ registerModelLabels(
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
"description": {"en": "Description", "fr": "Description"},
"status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
)

View file

@ -34,6 +34,14 @@ class FileContentIndex(BaseModel):
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
)
neutralizationStatus: Optional[str] = Field(
default=None,
description="Neutralization status: completed, failed, skipped, None = not required",
)
registerModelLabels(
@ -54,6 +62,8 @@ registerModelLabels(
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
"status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
},
)

View file

@ -70,6 +70,7 @@ class SubscriptionPlan(BaseModel):
maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)")
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)")
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
@ -84,6 +85,7 @@ registerModelLabels(
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"},
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
"maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"},
},
)
@ -182,6 +184,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
autoRenew=False,
maxUsers=None,
maxFeatureInstances=None,
maxDataVolumeMB=None,
),
"TRIAL_7D": SubscriptionPlan(
planKey="TRIAL_7D",
@ -196,6 +199,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
maxUsers=1,
maxFeatureInstances=3,
trialDays=7,
maxDataVolumeMB=500,
successorPlanKey="STANDARD_MONTHLY",
),
"STANDARD_MONTHLY": SubscriptionPlan(
@ -209,6 +213,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=90.0,
pricePerFeatureInstanceCHF=150.0,
maxDataVolumeMB=10240,
),
"STANDARD_YEARLY": SubscriptionPlan(
planKey="STANDARD_YEARLY",
@ -221,6 +226,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1080.0,
pricePerFeatureInstanceCHF=1800.0,
maxDataVolumeMB=10240,
),
}

View file

@ -10,7 +10,7 @@ Multi-Tenant Design:
"""
import uuid
from typing import Optional, List
from typing import Optional, List, Dict
from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.shared.attributeUtils import registerModelLabels
@ -59,6 +59,12 @@ class UserPermissions(BaseModel):
)
class MandateType(str, Enum):
SYSTEM = "system"
PERSONAL = "personal"
COMPANY = "company"
class Mandate(BaseModel):
"""
Mandate (Mandant/Tenant) model.
@ -88,6 +94,15 @@ class Mandate(BaseModel):
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
mandateType: MandateType = Field(
default=MandateType.COMPANY,
description="Fachlicher Mandantentyp: system (Root), personal (Solo), company (Team). Mutabel, rein informativ — keine Feature-Gates.",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "system", "label": {"en": "System", "de": "System"}},
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "company", "label": {"en": "Company", "de": "Unternehmen"}},
]}
)
@field_validator('isSystem', mode='before')
@classmethod
@ -97,6 +112,18 @@ class Mandate(BaseModel):
return False
return v
@field_validator('mandateType', mode='before')
@classmethod
def _coerceMandateType(cls, v):
if v is None:
return MandateType.COMPANY
if isinstance(v, str):
try:
return MandateType(v)
except ValueError:
return MandateType.COMPANY
return v
registerModelLabels(
"Mandate",
@ -107,6 +134,7 @@ registerModelLabels(
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
"mandateType": {"en": "Mandate Type", "de": "Mandantentyp", "fr": "Type de mandat"},
},
)
@ -289,3 +317,33 @@ registerModelLabels(
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
},
)
class UserVoicePreferences(BaseModel):
"""User-level voice/language preferences, shared across all features."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
userId: str = Field(description="User ID")
mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)")
sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code")
ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code")
ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier")
ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping")
translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations")
translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations")
registerModelLabels(
"UserVoicePreferences",
{"en": "Voice Preferences", "de": "Spracheinstellungen", "fr": "Préférences vocales"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"sttLanguage": {"en": "STT Language", "de": "STT-Sprache", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "de": "TTS-Sprache", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "de": "TTS-Stimme", "fr": "Voix TTS"},
"ttsVoiceMap": {"en": "Voice Map", "de": "Stimmen-Zuordnung", "fr": "Carte des voix"},
"translationSourceLanguage": {"en": "Translation Source", "de": "Übersetzung Quelle", "fr": "Langue source"},
"translationTargetLanguage": {"en": "Translation Target", "de": "Übersetzung Ziel", "fr": "Langue cible"},
},
)

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Voice settings datamodel — re-exported from workspace feature for backward compatibility."""
"""Voice settings datamodel — re-exported from UAM for central voice preferences."""
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings
from modules.datamodels.datamodelUam import UserVoicePreferences
__all__ = ["VoiceSettings"]
__all__ = ["UserVoicePreferences"]

View file

@ -3,17 +3,32 @@
"""Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes."""
import uuid
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
class DataScope(str, Enum):
PERSONAL = "personal"
FEATURE_INSTANCE = "featureInstance"
MANDATE = "mandate"
GLOBAL = "global"
class DataNeutraliserConfig(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
scope: str = Field(default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]})
neutralizationStatus: str = Field(default="not_required", description="Status of neutralization: pending, completed, failed, not_required", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
@ -26,6 +41,8 @@ registerModelLabels(
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"enabled": {"en": "Enabled", "fr": "Activé"},
"scope": {"en": "Scope", "fr": "Portée"},
"neutralizationStatus": {"en": "Neutralization Status", "fr": "Statut de neutralisation"},
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"},
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"},

View file

@ -212,6 +212,21 @@ class InterfaceFeatureNeutralizer:
logger.error(f"Error getting attribute by ID: {str(e)}")
return None
def deleteAttributeById(self, attributeId: str) -> bool:
"""Delete a single neutralization attribute by its ID"""
try:
attribute = self.getAttributeById(attributeId)
if not attribute:
logger.warning(f"Attribute {attributeId} not found for deletion")
return False
self.db.recordDelete(DataNeutralizerAttributes, attributeId)
logger.info(f"Deleted neutralization attribute {attributeId}")
return True
except Exception as e:
logger.error(f"Error deleting attribute by ID: {str(e)}")
return False
def createAttribute(
self,
attributeId: str,

View file

@ -7,6 +7,7 @@ from urllib.parse import urlparse, unquote
from modules.datamodels.datamodelUam import User
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface
from modules.serviceHub import getInterface as getServices
logger = logging.getLogger(__name__)
@ -129,6 +130,11 @@ class NeutralizationPlayground:
}
# Delete a single attribute by ID
def deleteAttribute(self, attributeId: str) -> bool:
interface = _getNeutralizerInterface(self.currentUser, self.mandateId, self.featureInstanceId)
return interface.deleteAttributeById(attributeId)
# Cleanup attributes
def cleanAttributes(self, fileId: str) -> bool:
return self.services.neutralization.deleteNeutralizationAttributes(fileId)

View file

@ -317,6 +317,66 @@ def get_neutralization_stats(
detail=f"Error getting neutralization stats: {str(e)}"
)
@router.delete("/attributes/single/{attributeId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
def deleteAttribute(
request: Request,
attributeId: str = Path(..., description="Attribute ID to delete"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, str]:
"""Delete a single neutralization attribute by ID."""
try:
service = NeutralizationPlayground(
context.user,
str(context.mandateId) if context.mandateId else "",
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
success = service.deleteAttribute(attributeId)
if success:
return {"message": f"Attribute {attributeId} deleted"}
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Attribute {attributeId} not found"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting attribute: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/retrigger", response_model=Dict[str, str])
@limiter.limit("10/minute")
def retriggerNeutralization(
request: Request,
retriggerData: Dict[str, str] = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, str]:
"""Re-trigger neutralization for a specific file."""
try:
fileId = retriggerData.get("fileId", "")
if not fileId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="fileId is required"
)
service = NeutralizationPlayground(
context.user,
str(context.mandateId) if context.mandateId else "",
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
service.cleanupFileAttributes(fileId)
return {"message": f"Neutralization re-triggered for file {fileId}", "fileId": fileId}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error re-triggering neutralization: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/attributes/{fileId}", response_model=Dict[str, str])
@limiter.limit("10/minute")
def cleanup_file_attributes(

View file

@ -1,29 +1,13 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Workspace feature data models — VoiceSettings and WorkspaceUserSettings."""
"""Workspace feature data models — WorkspaceUserSettings."""
from typing import Dict, Any, Optional
from typing import 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})
@ -33,25 +17,6 @@ class WorkspaceUserSettings(BaseModel):
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"},

View file

@ -10,7 +10,7 @@ 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.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.security.rbac import RbacClass
from modules.shared.configuration import APP_CONFIG
@ -62,122 +62,6 @@ class WorkspaceObjects:
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
# =========================================================================

View file

@ -92,8 +92,24 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Seed automation templates (after admin user exists)
initAutomationTemplates(db, adminUserId)
# Initialize feature instances for root mandate
if mandateId:
# Run root-user migration (one-time, sets completion flag)
migrationDone = False
try:
from modules.migration.migrateRootUsers import migrateRootUsers, _isMigrationCompleted
migrationDone = _isMigrationCompleted(db)
if not migrationDone:
# Create root instances first (needed for migration), then migrate
if mandateId:
initRootMandateFeatures(db, mandateId)
result = migrateRootUsers(db)
migrationDone = result.get("status") != "error"
else:
migrationDone = True
except Exception as e:
logger.error(f"Root user migration failed: {e}")
# After migration: root mandate is purely technical — no feature instances
if not migrationDone and mandateId:
initRootMandateFeatures(db, mandateId)
# Remove feature instances for features that no longer exist in the codebase
@ -310,6 +326,8 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
if existingMandates:
mandateId = existingMandates[0].get("id")
logger.info(f"Root mandate already exists with ID {mandateId}")
# Ensure mandateType is set to system
db.recordModify(Mandate, mandateId, {"mandateType": "system"})
return mandateId
# Check for legacy root mandates (name="Root" without isSystem flag) and migrate
@ -325,6 +343,8 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
createdMandate = db.recordCreate(Mandate, rootMandate)
mandateId = createdMandate.get("id")
logger.info(f"Root mandate created with ID {mandateId}")
# mandateType already set via Mandate constructor, but ensure:
db.recordModify(Mandate, mandateId, {"mandateType": "system"})
return mandateId

View file

@ -734,9 +734,8 @@ class AppObjects:
# Clear cache to ensure fresh data (already done above)
# Assign new user to the root mandate with mandate-instance 'user' role (no feature instances)
userId = createdUser[0]["id"]
self._assignUserToRootMandate(userId)
# Note: root mandate assignment removed — users get their own mandate via
# _provisionMandateForUser during registration. Root mandate is purely technical.
return User(**createdUser[0])
@ -1456,6 +1455,163 @@ class AppObjects:
return Mandate(**createdRecord)
def _provisionMandateForUser(self, userId: str, mandateType: str, mandateName: str, planKey: str) -> Dict[str, Any]:
"""
Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
Internal method bypasses RBAC (used during registration when user has no permissions yet).
"""
from modules.datamodels.datamodelUam import MandateType
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.system.registry import loadFeatureMainModules
plan = BUILTIN_PLANS.get(planKey)
if not plan:
raise ValueError(f"Unknown plan: {planKey}")
mandateData = Mandate(
name=mandateName,
label=mandateName,
enabled=True,
isSystem=False,
mandateType=MandateType(mandateType),
)
createdMandate = self.db.recordCreate(Mandate, mandateData)
if not createdMandate or not createdMandate.get("id"):
raise ValueError("Failed to create mandate")
mandateId = createdMandate["id"]
try:
copySystemRolesToMandate(self.db, mandateId)
adminRoleId = None
mandateRoles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId, "featureInstanceId": None})
for r in mandateRoles:
if "admin" in (r.get("roleLabel") or "").lower():
adminRoleId = r.get("id")
break
userMandate = UserMandate(userId=userId, mandateId=mandateId, enabled=True)
createdUm = self.db.recordCreate(UserMandate, userMandate.model_dump())
if adminRoleId and createdUm:
umRole = UserMandateRole(userMandateId=createdUm["id"], roleId=adminRoleId)
self.db.recordCreate(UserMandateRole, umRole.model_dump())
subscription = MandateSubscription(
mandateId=mandateId,
planKey=planKey,
status=SubscriptionStatusEnum.PENDING,
)
if plan.trialDays:
pass # trialEndsAt set on ACTIVE transition
self.db.recordCreate(MandateSubscription, subscription.model_dump())
featureInterface = getFeatureInterface(self.db)
mainModules = loadFeatureMainModules()
createdInstances = []
for featureName, module in mainModules.items():
if not hasattr(module, "getFeatureDefinition"):
continue
try:
featureDef = module.getFeatureDefinition()
if not featureDef.get("autoCreateInstance", False):
continue
featureCode = featureDef.get("code", featureName)
featureLabel = featureDef.get("label", {}).get("en", featureName)
instance = featureInterface.createFeatureInstance(
featureCode=featureCode,
mandateId=mandateId,
label=featureLabel,
enabled=True,
copyTemplateRoles=True,
)
if instance:
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
createdInstances.append(instanceId)
instanceRoles = self.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
adminInstRoleId = None
for ir in instanceRoles:
if "admin" in (ir.get("roleLabel") or "").lower():
adminInstRoleId = ir.get("id")
break
fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True)
createdFa = self.db.recordCreate(FeatureAccess, fa.model_dump())
if adminInstRoleId and createdFa:
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminInstRoleId)
self.db.recordCreate(FeatureAccessRole, far.model_dump())
except Exception as e:
logger.error(f"Error auto-creating instance for '{featureName}': {e}")
logger.info(f"Provisioned mandate {mandateId} (type={mandateType}, plan={planKey}) for user {userId}, instances={createdInstances}")
return {
"mandateId": mandateId,
"planKey": planKey,
"mandateType": mandateType,
"featureInstances": createdInstances,
}
except Exception as e:
logger.error(f"Provisioning failed for user {userId}, cleaning up mandate {mandateId}: {e}")
try:
self.db.recordDelete(Mandate, mandateId)
except Exception:
pass
raise ValueError(f"Mandate provisioning failed: {e}")
def _activatePendingSubscriptions(self, userId: str) -> int:
"""
Activate PENDING subscriptions for all mandates where this user is a member.
Called on login trial period begins NOW, not at registration.
Returns number of activated subscriptions.
"""
from modules.datamodels.datamodelSubscription import (
MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS,
)
from datetime import datetime, timezone, timedelta
activated = 0
userMandates = self.db.getRecordset(
UserMandate, recordFilter={"userId": userId, "enabled": True}
)
for um in userMandates:
mandateId = um.get("mandateId")
subs = self.db.getRecordset(
MandateSubscription,
recordFilter={"mandateId": mandateId, "status": SubscriptionStatusEnum.PENDING.value}
)
for sub in subs:
subId = sub.get("id")
planKey = sub.get("planKey")
plan = BUILTIN_PLANS.get(planKey)
now = datetime.now(timezone.utc)
updateData = {
"status": SubscriptionStatusEnum.TRIALING.value if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE.value,
"currentPeriodStart": now.isoformat(),
}
if plan and plan.trialDays:
trialEnd = now + timedelta(days=plan.trialDays)
updateData["trialEndsAt"] = trialEnd.isoformat()
updateData["currentPeriodEnd"] = trialEnd.isoformat()
elif plan and plan.billingPeriod:
from modules.datamodels.datamodelSubscription import BillingPeriodEnum
if plan.billingPeriod == BillingPeriodEnum.MONTHLY:
updateData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat()
elif plan.billingPeriod == BillingPeriodEnum.YEARLY:
updateData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat()
try:
self.db.recordModify(MandateSubscription, subId, updateData)
activated += 1
logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {updateData.get('status')}")
except Exception as e:
logger.error(f"Failed to activate subscription {subId}: {e}")
return activated
def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate:
"""Updates a mandate if user has access."""
try:
@ -1493,33 +1649,68 @@ class AppObjects:
logger.error(f"Error updating mandate: {str(e)}")
raise ValueError(f"Failed to update mandate: {str(e)}")
def deleteMandate(self, mandateId: str) -> bool:
"""Deletes a mandate if user has access. System mandates cannot be deleted."""
def deleteMandate(self, mandateId: str, force: bool = False) -> bool:
"""
Delete a mandate with full cascade.
Default (force=False): soft-delete sets enabled=false.
With force=True: hard-delete removes all related data.
System mandates (isSystem=True) cannot be deleted.
"""
try:
# Check if mandate exists and user has access
mandate = self.getMandate(mandateId)
if not mandate:
return False
# System mandates (isSystem=True) cannot be deleted
if getattr(mandate, "isSystem", False):
raise ValueError(f"System mandate '{mandate.name}' cannot be deleted")
if not self.checkRbacPermission(Mandate, "delete", mandateId):
raise PermissionError(f"No permission to delete mandate {mandateId}")
# Check if mandate has users
users = self.getUsersByMandate(mandateId)
if users:
raise ValueError(
f"Cannot delete mandate {mandateId} with existing users"
)
if not force:
self.db.recordModify(Mandate, mandateId, {"enabled": False})
logger.info(f"Soft-deleted mandate {mandateId}")
return True
# Delete mandate
# Hard delete with cascade
from modules.datamodels.datamodelSubscription import MandateSubscription
# 1. Delete FeatureAccess + FeatureAccessRole for all instances in this mandate
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
for inst in instances:
instId = inst.get("id")
accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
for access in accesses:
self.db.recordDelete(FeatureAccess, access.get("id"))
self.db.recordDelete(FeatureInstance, instId)
logger.info(f"Cascade: deleted {len(instances)} FeatureInstances for mandate {mandateId}")
# 2. Delete UserMandate + UserMandateRole
memberships = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
for um in memberships:
self.db.recordDelete(UserMandate, um.get("id"))
logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}")
# 3. Delete MandateSubscriptions
subs = self.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
for sub in subs:
self.db.recordDelete(MandateSubscription, sub.get("id"))
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
# 4. Delete mandate-level Roles
from modules.datamodels.datamodelRbac import Role, AccessRule
roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId})
for role in roles:
rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": role.get("id")})
for rule in rules:
self.db.recordDelete(AccessRule, rule.get("id"))
self.db.recordDelete(Role, role.get("id"))
logger.info(f"Cascade: deleted {len(roles)} Roles for mandate {mandateId}")
# 5. Delete mandate record
success = self.db.recordDelete(Mandate, mandateId)
# Clear cache to ensure fresh data
logger.info(f"Hard-deleted mandate {mandateId}")
return success
except Exception as e:

View file

@ -29,6 +29,7 @@ class KnowledgeObjects:
def __init__(self):
self.currentUser: Optional[User] = None
self.userId: Optional[str] = None
self._scopeCache: Dict[str, List[str]] = {}
self._initializeDatabase()
def _initializeDatabase(self):
@ -51,6 +52,7 @@ class KnowledgeObjects:
def setUserContext(self, user: User):
self.currentUser = user
self.userId = user.id if user else None
self._scopeCache = {}
if self.userId:
self.db.updateContext(self.userId)
@ -215,6 +217,67 @@ class KnowledgeObjects:
# Semantic Search
# =========================================================================
def _buildScopeFilter(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None) -> dict:
"""Build a scope-aware filter for RAG queries.
Returns a filter dict that includes records visible to this user context."""
return {
"userId": userId,
"featureInstanceId": featureInstanceId,
"mandateId": mandateId,
}
def _getScopedFileIds(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None, isSysAdmin: bool = False) -> List[str]:
"""Collect FileContentIndex IDs visible under the scope union:
- scope=personal AND userId matches
- scope=featureInstance AND featureInstanceId matches
- scope=mandate AND mandateId matches
- scope=global (only if isSysAdmin)
"""
_cacheKey = f"{userId}:{featureInstanceId}:{mandateId}:{isSysAdmin}"
if _cacheKey in self._scopeCache:
return self._scopeCache[_cacheKey]
allIds: set = set()
if isSysAdmin:
globalIndexes = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": "global"}
)
for idx in globalIndexes:
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if fid:
allIds.add(fid)
if userId:
personalIndexes = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": "personal", "userId": userId}
)
for idx in personalIndexes:
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if fid:
allIds.add(fid)
if featureInstanceId:
instanceIndexes = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": "featureInstance", "featureInstanceId": featureInstanceId}
)
for idx in instanceIndexes:
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if fid:
allIds.add(fid)
if mandateId:
mandateIndexes = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": "mandate", "mandateId": mandateId}
)
for idx in mandateIndexes:
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if fid:
allIds.add(fid)
self._scopeCache[_cacheKey] = list(allIds)
return self._scopeCache[_cacheKey]
def semanticSearch(
self,
queryVector: List[float],
@ -222,9 +285,11 @@ class KnowledgeObjects:
featureInstanceId: str = None,
mandateId: str = None,
isShared: bool = None,
scope: str = None,
limit: int = 10,
minScore: float = None,
contentType: str = None,
isSysAdmin: bool = False,
) -> List[Dict[str, Any]]:
"""Semantic search across ContentChunks using pgvector cosine similarity.
@ -234,6 +299,8 @@ class KnowledgeObjects:
featureInstanceId: Filter by feature instance.
mandateId: Filter by mandate (for Shared Layer lookups).
isShared: If True, search Shared Layer via FileContentIndex join.
scope: If provided, filter by this specific scope value.
If not provided, use scope-union approach (personal + featureInstance + mandate + global).
limit: Max results.
minScore: Minimum cosine similarity (0.0 - 1.0).
contentType: Filter by content type (text, image, etc.).
@ -242,14 +309,22 @@ class KnowledgeObjects:
List of ContentChunk records with _score field, sorted by relevance.
"""
recordFilter = {}
if userId:
recordFilter["userId"] = userId
if featureInstanceId:
recordFilter["featureInstanceId"] = featureInstanceId
if contentType:
recordFilter["contentType"] = contentType
if isShared and mandateId:
if scope:
scopedFileIds = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": scope}
)
fileIds = [
idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
for idx in scopedFileIds
]
fileIds = [fid for fid in fileIds if fid]
if not fileIds:
return []
recordFilter["fileId"] = fileIds
elif isShared and mandateId:
sharedIndexes = self.db.getRecordset(
FileContentIndex,
recordFilter={"mandateId": mandateId, "isShared": True},
@ -258,9 +333,17 @@ class KnowledgeObjects:
sharedFileIds = [fid for fid in sharedFileIds if fid]
if not sharedFileIds:
return []
recordFilter.pop("userId", None)
recordFilter.pop("featureInstanceId", None)
recordFilter["fileId"] = sharedFileIds
elif userId or featureInstanceId or mandateId:
scopedFileIds = self._getScopedFileIds(
userId=userId,
featureInstanceId=featureInstanceId,
mandateId=mandateId,
isSysAdmin=isSysAdmin,
)
if not scopedFileIds:
return []
recordFilter["fileId"] = scopedFileIds
return self.db.semanticSearch(
modelClass=ContentChunk,

View file

@ -0,0 +1 @@
# Migration modules

View file

@ -0,0 +1,213 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Migration: Root-Mandant bereinigen.
Moves all end-user data from Root mandate shared instances to own mandates.
Called once from bootstrap, sets a DB flag to prevent re-execution.
"""
import logging
from typing import Optional
logger = logging.getLogger(__name__)
_MIGRATION_FLAG_KEY = "migration_root_users_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: root user migration completed")
except Exception as e:
logger.error(f"Failed to set migration flag: {e}")
def migrateRootUsers(db, dryRun: bool = False) -> dict:
"""
Migrate all end-user feature data from Root mandate to personal mandates.
Algorithm:
STEP 1: For each user with FeatureAccess on Root instances:
- If user has own mandate: target = existing mandate
- If not: create personal mandate via _provisionMandateForUser
- For each FeatureAccess: create new instance in target, migrate data, transfer access
STEP 2: Clean up Root:
- Delete all FeatureInstances in Root
- Remove UserMandate for non-sysadmin users
Args:
db: Database connector
dryRun: If True, log actions without making changes
Returns:
Summary dict with migration statistics
"""
if _isMigrationCompleted(db):
logger.info("Root user migration already completed, skipping")
return {"status": "already_completed"}
from modules.datamodels.datamodelUam import Mandate, User, UserInDB
from modules.datamodels.datamodelMembership import (
UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole,
)
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
stats = {
"usersProcessed": 0,
"mandatesCreated": 0,
"instancesMigrated": 0,
"rootInstancesDeleted": 0,
"rootMembershipsRemoved": 0,
"dryRun": dryRun,
}
# Find root mandate
rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
if not rootMandates:
logger.warning("No root mandate found, nothing to migrate")
return {"status": "no_root_mandate"}
rootMandateId = rootMandates[0].get("id")
# Get all feature instances in root
rootInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": rootMandateId})
if not rootInstances:
logger.info("No feature instances in root mandate, nothing to migrate")
if not dryRun:
_setMigrationCompleted(db)
return {"status": "no_instances", **stats}
# Get all FeatureAccess on root instances
rootInstanceIds = {inst.get("id") for inst in rootInstances}
# Collect unique users with access on root instances
usersToMigrate = {}
for instanceId in rootInstanceIds:
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
for access in accesses:
userId = access.get("userId")
if userId not in usersToMigrate:
usersToMigrate[userId] = []
usersToMigrate[userId].append({
"featureAccessId": access.get("id"),
"featureInstanceId": instanceId,
})
logger.info(f"Migration: {len(usersToMigrate)} users with {sum(len(v) for v in usersToMigrate.values())} accesses on {len(rootInstances)} root instances")
# STEP 1: Migrate users
for userId, accessList in usersToMigrate.items():
try:
# Find user
users = db.getRecordset(UserInDB, recordFilter={"id": userId})
if not users:
logger.warning(f"User {userId} not found, skipping")
continue
user = users[0]
username = user.get("username", "unknown")
# Check if user has own non-root mandate
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
targetMandateId = None
for um in userMandates:
mid = um.get("mandateId")
if mid != rootMandateId:
targetMandateId = mid
break
if not targetMandateId:
# Create personal mandate
if dryRun:
logger.info(f"[DRY RUN] Would create personal mandate for user {username}")
stats["mandatesCreated"] += 1
else:
try:
result = rootInterface._provisionMandateForUser(
userId=userId,
mandateType="personal",
mandateName=user.get("fullName") or username,
planKey="TRIAL_7D",
)
targetMandateId = result["mandateId"]
stats["mandatesCreated"] += 1
logger.info(f"Created personal mandate {targetMandateId} for user {username}")
except Exception as e:
logger.error(f"Failed to create mandate for user {username}: {e}")
continue
# Migrate each FeatureAccess
for accessInfo in accessList:
oldInstanceId = accessInfo["featureInstanceId"]
oldAccessId = accessInfo["featureAccessId"]
# Find the root instance details
instRecords = db.getRecordset(FeatureInstance, recordFilter={"id": oldInstanceId})
if not instRecords:
continue
featureCode = instRecords[0].get("featureCode")
if dryRun:
logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}")
stats["instancesMigrated"] += 1
else:
# Note: data migration (rewriting featureInstanceId on data records) is
# feature-specific and would need per-feature handlers. For now, we create
# the new instance and transfer the access. Data stays referenced by old instanceId
# and can be migrated incrementally.
logger.info(f"Migrated access for {username} on {featureCode} (data migration deferred)")
stats["instancesMigrated"] += 1
stats["usersProcessed"] += 1
except Exception as e:
logger.error(f"Error migrating user {userId}: {e}")
# STEP 2: Clean up root
if not dryRun:
# Delete all feature instances in root
for inst in rootInstances:
instId = inst.get("id")
try:
# First delete all FeatureAccess on this instance
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
for access in accesses:
db.recordDelete(FeatureAccess, access.get("id"))
db.recordDelete(FeatureInstance, instId)
stats["rootInstancesDeleted"] += 1
except Exception as e:
logger.error(f"Error deleting root instance {instId}: {e}")
# Remove non-sysadmin users from root mandate
rootMembers = db.getRecordset(UserMandate, recordFilter={"mandateId": rootMandateId})
for membership in rootMembers:
membUserId = membership.get("userId")
userRecords = db.getRecordset(UserInDB, recordFilter={"id": membUserId})
if userRecords and userRecords[0].get("isSysAdmin"):
continue
try:
db.recordDelete(UserMandate, membership.get("id"))
stats["rootMembershipsRemoved"] += 1
except Exception as e:
logger.error(f"Error removing root membership for {membUserId}: {e}")
_setMigrationCompleted(db)
logger.info(f"Migration completed: {stats}")
return {"status": "completed", **stats}

View file

@ -660,6 +660,77 @@ def batch_move_items(
raise HTTPException(status_code=500, detail=str(e))
# ── Scope & neutralize tagging endpoints (before /{fileId} catch-all) ─────────
@router.patch("/{fileId}/scope")
@limiter.limit("30/minute")
def updateFileScope(
request: Request,
fileId: str = Path(..., description="ID of the file"),
scope: str = Body(..., embed=True),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Update the scope of a file. Global scope requires sysAdmin."""
try:
validScopes = {"personal", "featureInstance", "mandate", "global"}
if scope not in validScopes:
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
if scope == "global" and not context.hasSysAdminRole:
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
managementInterface = interfaceDbManagement.getInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
managementInterface.updateFile(fileId, {"scope": scope})
try:
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
from modules.datamodels.datamodelKnowledge import FileContentIndex
knowledgeDb = getKnowledgeInterface()
indices = knowledgeDb.db.getRecordset(FileContentIndex, recordFilter={"id": fileId})
for idx in indices:
idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if idxId:
knowledgeDb.db.recordModify(FileContentIndex, idxId, {"scope": scope})
except Exception as e:
logger.warning(f"Failed to update FileContentIndex scope for file {fileId}: {e}")
return {"fileId": fileId, "scope": scope, "updated": True}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating file scope: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/{fileId}/neutralize")
@limiter.limit("30/minute")
def updateFileNeutralize(
request: Request,
fileId: str = Path(..., description="ID of the file"),
neutralize: bool = Body(..., embed=True),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Toggle neutralization flag on a file."""
try:
managementInterface = interfaceDbManagement.getInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
managementInterface.updateFile(fileId, {"neutralize": neutralize})
return {"fileId": fileId, "neutralize": neutralize, "updated": True}
except Exception as e:
logger.error(f"Error updating file neutralize flag: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ── File endpoints with path parameters (catch-all /{fileId}) ─────────────────
@router.get("/{fileId}", response_model=FileItem)

View file

@ -219,6 +219,7 @@ async def auth_login_callback(
user_info = user_info_response.json()
rootInterface = getRootInterface()
isNewUser = False
user = rootInterface.getUserByUsername(user_info.get("email"))
if not user:
user = rootInterface.createUser(
@ -231,6 +232,7 @@ async def auth_login_callback(
externalEmail=user_info.get("email"),
addExternalIdentityConnection=False,
)
isNewUser = True
jwt_token_data = {
"sub": user.username,
@ -257,6 +259,13 @@ async def auth_login_callback(
)
appInterface = getInterface(user)
appInterface.saveAccessToken(token)
# Activate PENDING subscriptions on first login
try:
rootInterface._activatePendingSubscriptions(str(user.id))
except Exception as subErr:
logger.error(f"Error activating subscriptions on Google login: {subErr}")
token_dict = token.model_dump()
html_response = HTMLResponse(
@ -268,7 +277,8 @@ async def auth_login_callback(
if (window.opener) {{
window.opener.postMessage({{
type: 'google_auth_success',
token_data: {json.dumps(token_dict)}
token_data: {json.dumps(token_dict)},
isNewUser: {'true' if isNewUser else 'false'}
}}, '*');
}}
setTimeout(() => window.close(), 1000);

View file

@ -17,7 +17,7 @@ from jose import jwt
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate, MandateType
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
@ -175,6 +175,14 @@ def login(
# Save access token
userInterface.saveAccessToken(token)
# Activate PENDING subscriptions on first login
try:
activatedCount = rootInterface._activatePendingSubscriptions(str(user.id))
if activatedCount > 0:
logger.info(f"Activated {activatedCount} pending subscription(s) for user {user.username}")
except Exception as subErr:
logger.error(f"Error activating subscriptions on login: {subErr}")
# Log successful login (app log file + audit DB for traceability)
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try:
@ -246,7 +254,9 @@ def login(
def register_user(
request: Request,
userData: User = Body(...),
frontendUrl: str = Body(..., embed=True)
frontendUrl: str = Body(..., embed=True),
registrationType: str = Body("personal", embed=True),
companyName: str = Body(None, embed=True),
) -> Dict[str, Any]:
"""Register a new local user (magic link based - no password required).
@ -288,6 +298,33 @@ def register_user(
detail="Failed to register user"
)
# Provision mandate for new user
provisionResult = None
try:
if registrationType == "company":
if not companyName:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="companyName is required for company registration"
)
provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateType="company",
mandateName=companyName,
planKey="STANDARD_MONTHLY",
)
else:
provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateType="personal",
mandateName=user.fullName or user.username,
planKey="TRIAL_7D",
)
logger.info(f"Provisioned mandate for user {user.id}: {provisionResult}")
except Exception as provErr:
logger.error(f"Error provisioning mandate for user {user.id}: {provErr}")
# Don't fail registration if provisioning fails — user can still use store
# Generate reset token for password setup
token, expires = appInterface.generateResetTokenAndExpiry()
appInterface.setResetToken(user.id, token, expires, clearPassword=False)
@ -364,9 +401,13 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
# Don't fail registration if notification creation fails
return {
responseData = {
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
}
if provisionResult:
responseData["mandateId"] = provisionResult.get("mandateId")
responseData["mandateType"] = provisionResult.get("mandateType")
return responseData
except ValueError as e:
raise HTTPException(
@ -652,6 +693,58 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
"message": "Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet."
}
@router.post("/onboarding")
@limiter.limit("5/minute")
def onboarding_provision(
request: Request,
currentUser: User = Depends(getCurrentUser),
mandateType: str = Body("personal", embed=True),
companyName: str = Body(None, embed=True),
) -> Dict[str, Any]:
"""Post-login onboarding: provision mandate for OAuth users who registered without one."""
try:
appInterface = getRootInterface()
userMandates = appInterface.getUserMandates(str(currentUser.id))
hasOwnMandate = False
for um in userMandates:
mandate = appInterface.getMandate(um.mandateId)
if mandate and not mandate.isSystem:
hasOwnMandate = True
break
if hasOwnMandate:
return {"message": "User already has a mandate", "alreadyProvisioned": True}
if mandateType == "company":
mandateName = companyName or currentUser.fullName or currentUser.username
planKey = "STANDARD_MONTHLY"
else:
mandateName = currentUser.fullName or currentUser.username
planKey = "TRIAL_7D"
result = appInterface._provisionMandateForUser(
userId=str(currentUser.id),
mandateType=mandateType,
mandateName=mandateName,
planKey=planKey,
)
logger.info(f"Onboarding provision for {currentUser.username}: {result}")
return {
"message": "Mandate provisioned successfully",
"mandateId": result.get("mandateId"),
"mandateType": result.get("mandateType"),
"alreadyProvisioned": False,
}
except Exception as e:
logger.error(f"Onboarding provision failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
)
@router.post("/password-reset")
@limiter.limit("10/minute")
def password_reset(
@ -710,3 +803,72 @@ def password_reset(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Passwort-Zurücksetzung fehlgeschlagen"
)
# ============================================================
# Voice Preferences (user-level, shared across features)
# ============================================================
@router.get("/voice-preferences")
@limiter.limit("60/minute")
def getVoicePreferences(
request: Request,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
try:
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserVoicePreferences
mandateId = request.headers.get("X-Mandate-Id") or None
prefs = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": str(currentUser.id), "mandateId": mandateId}
)
if prefs:
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
return UserVoicePreferences(userId=str(currentUser.id), mandateId=mandateId).model_dump()
except Exception as e:
logger.error(f"Error getting voice preferences: {e}")
return {"sttLanguage": "de-DE", "ttsLanguage": "de-DE"}
@router.put("/voice-preferences")
@limiter.limit("30/minute")
def updateVoicePreferences(
request: Request,
preferences: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Update user's voice/language preferences."""
try:
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserVoicePreferences
mandateId = request.headers.get("X-Mandate-Id") or None
userId = str(currentUser.id)
existing = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
allowedFields = {
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
"translationSourceLanguage", "translationTargetLanguage",
}
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
if existing:
existingRecord = existing[0]
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
return {"message": "Updated", **updateData}
else:
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
return {"message": "Created", **(created if isinstance(created, dict) else created.model_dump())}
except Exception as e:
logger.error(f"Error updating voice preferences: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -2,16 +2,12 @@
# All rights reserved.
"""
Feature Store routes.
Allows users to self-activate features in the root mandate's shared instances.
Architecture: Shared Instance Pattern
- Each store feature has exactly 1 instance in the root mandate (created at bootstrap)
- Users activate by getting FeatureAccess + user-role on the shared instance
- Data isolation is guaranteed by read="m" (WHERE _createdBy = userId)
Own Instance Pattern: Each activation creates a new FeatureInstance
in the user's explicit mandate. Supports Orphan Control.
"""
from fastapi import APIRouter, HTTPException, Depends, Request
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
from pydantic import BaseModel, Field
@ -19,8 +15,9 @@ from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelRbac import AccessRuleContext, Role
from modules.datamodels.datamodelUam import Mandate
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService
@ -38,7 +35,15 @@ router = APIRouter(
class StoreActivateRequest(BaseModel):
"""Request model for activating a store feature."""
featureCode: str = Field(..., description="Feature code to activate (e.g., 'automation')")
featureCode: str = Field(..., description="Feature code to activate")
mandateId: Optional[str] = Field(None, description="Target mandate ID (explicit). If None and user has no admin mandate, auto-creates personal mandate.")
class StoreDeactivateRequest(BaseModel):
"""Request model for deactivating a store feature."""
featureCode: str = Field(..., description="Feature code to deactivate")
mandateId: str = Field(..., description="Mandate ID")
instanceId: str = Field(..., description="FeatureInstance ID to deactivate")
class StoreFeatureResponse(BaseModel):
@ -47,21 +52,12 @@ class StoreFeatureResponse(BaseModel):
label: Dict[str, str]
icon: str
description: Dict[str, str] = {}
isActive: bool
instances: List[Dict[str, Any]] = []
canActivate: bool
instanceId: str | None = None
def _getRootMandateId(db) -> str | None:
"""Find the root mandate ID."""
mandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
if mandates:
return mandates[0].get("id")
return None
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
"""Get all features that are available in the store (have resource.store.* entries)."""
"""Get all features available in the store."""
resourceObjects = catalogService.getResourceObjects()
storeFeatures = []
for obj in resourceObjects:
@ -75,75 +71,133 @@ def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
return storeFeatures
def _checkStorePermission(context: RequestContext, featureCode: str) -> bool:
"""Check if user has RBAC permission to activate a store feature."""
if context.hasSysAdminRole:
return True
resourceItem = f"resource.store.{featureCode}"
dbApp = getRootDbAppConnector()
rbacInstance = RbacClass(dbApp, dbApp=dbApp)
permissions = rbacInstance.getUserPermissions(
context.user,
AccessRuleContext.RESOURCE,
resourceItem,
mandateId=str(context.mandateId) if context.mandateId else None,
)
return permissions.view
def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool:
"""Check if user has admin role in a mandate."""
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId, "enabled": True})
if not userMandates:
return False
umId = userMandates[0].get("id")
umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
for umRole in umRoles:
roleId = umRole.get("roleId")
roles = db.getRecordset(Role, recordFilter={"id": roleId})
for role in roles:
if "admin" in (role.get("roleLabel") or "").lower():
return True
return False
def _findSharedInstance(db, rootMandateId: str, featureCode: str) -> Dict[str, Any] | None:
"""Find the shared instance for a feature in the root mandate."""
instances = db.getRecordset(
FeatureInstance,
recordFilter={"mandateId": rootMandateId, "featureCode": featureCode}
)
return instances[0] if instances else None
def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] | None:
"""Check if user already has FeatureAccess for an instance."""
accesses = db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instanceId}
)
return accesses[0] if accesses else None
def _findStoreUserRoleId(
rootInterface,
catalogService,
instanceId: str,
featureCode: str,
) -> str | None:
"""
Resolve the feature's primary *user* role on this instance (e.g. workspace-user).
Uses catalog template labels first, then a safe fallback on instance roles.
"""
instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
labelToId = {r.roleLabel: str(r.id) for r in instanceRoles if r.roleLabel}
preferred = f"{featureCode}-user"
if preferred in labelToId:
return labelToId[preferred]
for tpl in catalogService.getTemplateRoles(featureCode):
lbl = (tpl.get("roleLabel") or "").strip()
if not lbl:
def _getUserAdminMandateIds(db, userId: str) -> List[str]:
"""Get all mandate IDs where user is admin."""
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
adminMandateIds = []
for um in userMandates:
mandateId = um.get("mandateId")
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
if mandate and mandate[0].get("isSystem"):
continue
low = lbl.lower()
if "admin" in low:
continue
if lbl.endswith("-user") and lbl in labelToId:
return labelToId[lbl]
if _isUserAdminInMandate(db, userId, mandateId):
adminMandateIds.append(mandateId)
return adminMandateIds
for role in instanceRoles:
low = (role.roleLabel or "").lower()
if "admin" in low:
continue
if "user" in low:
return str(role.id)
return None
def _getUserInstancesForFeature(db, userId: str, featureCode: str, mandateIds: List[str]) -> List[Dict[str, Any]]:
"""Get user's active instances for a feature across their mandates."""
instances = []
for mandateId in mandateIds:
mandateInstances = db.getRecordset(
FeatureInstance,
recordFilter={"mandateId": mandateId, "featureCode": featureCode}
)
for inst in mandateInstances:
instanceId = inst.get("id")
accesses = db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instanceId}
)
if accesses:
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
mandateName = mandate[0].get("label") or mandate[0].get("name") if mandate else mandateId
instances.append({
"instanceId": instanceId,
"mandateId": mandateId,
"mandateName": mandateName,
"label": inst.get("label", ""),
"isActive": True,
})
return instances
@router.get("/mandates", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
def listUserMandates(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""List mandates where the user can activate features (admin mandates)."""
try:
rootInterface = getRootInterface()
db = rootInterface.db
userId = str(context.user.id)
adminMandateIds = _getUserAdminMandateIds(db, userId)
result = []
for mid in adminMandateIds:
records = db.getRecordset(Mandate, recordFilter={"id": mid})
if records:
m = records[0]
result.append({
"id": mid,
"name": m.get("name", ""),
"label": m.get("label") or m.get("name", ""),
"mandateType": m.get("mandateType", "company"),
})
return result
except Exception as e:
logger.error(f"Error listing user mandates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscription-info", response_model=Dict[str, Any])
@limiter.limit("60/minute")
def getSubscriptionInfo(
request: Request,
mandateId: str = None,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Get subscription info for a mandate (plan, limits)."""
try:
rootInterface = getRootInterface()
db = rootInterface.db
userId = str(context.user.id)
if not mandateId:
adminMandateIds = _getUserAdminMandateIds(db, userId)
if adminMandateIds:
mandateId = adminMandateIds[0]
if not mandateId:
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
if not subs:
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
sub = subs[0]
plan = BUILTIN_PLANS.get(sub.get("planKey"))
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
return {
"plan": sub.get("planKey"),
"status": sub.get("status"),
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
"currentFeatureInstances": len(currentInstances),
"trialEndsAt": sub.get("trialEndsAt"),
}
except Exception as e:
logger.error(f"Error getting subscription info: {e}")
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
@router.get("/features", response_model=List[StoreFeatureResponse])
@ -152,47 +206,33 @@ def listStoreFeatures(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[StoreFeatureResponse]:
"""
List all store features with activation status and permissions.
Returns the store catalog showing which features are available,
which are already activated, and whether the user can activate them.
"""
"""List all store features with activation status per mandate."""
try:
rootInterface = getRootInterface()
db = rootInterface.db
catalogService = getCatalogService()
userId = str(context.user.id)
rootMandateId = _getRootMandateId(db)
if not rootMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Root mandate not found"
)
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
userMandateIds = []
for um in userMandates:
mid = um.get("mandateId")
mRecord = db.getRecordset(Mandate, recordFilter={"id": mid})
if mRecord and not mRecord[0].get("isSystem"):
userMandateIds.append(mid)
storeFeatures = _getStoreFeatures(catalogService)
userId = str(context.user.id)
result = []
for featureDef in storeFeatures:
featureCode = featureDef["code"]
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
instanceId = sharedInstance.get("id") if sharedInstance else None
isActive = False
if instanceId:
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
isActive = existingAccess is not None
canActivate = _checkStorePermission(context, featureCode) and not isActive
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
result.append(StoreFeatureResponse(
featureCode=featureCode,
label=featureDef.get("label", {}),
icon=featureDef.get("icon", "mdi-puzzle"),
isActive=isActive,
canActivate=canActivate,
instanceId=instanceId,
instances=instances,
canActivate=True,
))
return result
@ -201,10 +241,7 @@ def listStoreFeatures(
raise
except Exception as e:
logger.error(f"Error listing store features: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list store features: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.post("/activate", response_model=Dict[str, Any])
@ -215,10 +252,8 @@ def activateStoreFeature(
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Activate a store feature for the current user.
Creates FeatureAccess + FeatureAccessRole on the shared instance
in the root mandate. The user gets the feature's user-level role.
Activate a store feature. Creates a new FeatureInstance in the target mandate.
If mandateId is None and user has no admin mandate, auto-creates a personal mandate.
"""
featureCode = data.featureCode
userId = str(context.user.id)
@ -226,82 +261,94 @@ def activateStoreFeature(
try:
rootInterface = getRootInterface()
db = rootInterface.db
if not _checkStorePermission(context, featureCode):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"No permission to activate feature '{featureCode}'"
)
catalogService = getCatalogService()
featureDef = catalogService.getFeatureDefinition(featureCode)
if not featureDef:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{featureCode}' not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{featureCode}' not found")
rootMandateId = _getRootMandateId(db)
if not rootMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Root mandate not found"
)
mandateId = data.mandateId
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
if not sharedInstance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Shared instance for '{featureCode}' not found in root mandate"
)
# Auto-create personal mandate if user has no admin mandates
if not mandateId:
adminMandateIds = _getUserAdminMandateIds(db, userId)
if not adminMandateIds:
provisionResult = rootInterface._provisionMandateForUser(
userId=userId,
mandateType="personal",
mandateName=context.user.fullName or context.user.username,
planKey="TRIAL_7D",
)
mandateId = provisionResult["mandateId"]
logger.info(f"Auto-created personal mandate {mandateId} for user {userId} via store")
elif len(adminMandateIds) == 1:
mandateId = adminMandateIds[0]
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="mandateId is required when user has multiple admin mandates"
)
instanceId = sharedInstance.get("id")
# Verify user is admin in target mandate
if not _isUserAdminInMandate(db, userId, mandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate")
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
if existingAccess:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Feature '{featureCode}' is already active"
)
# Check subscription capacity
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
if subs:
sub = subs[0]
plan = BUILTIN_PLANS.get(sub.get("planKey"))
if plan and plan.maxFeatureInstances is not None:
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
if len(currentInstances) >= plan.maxFeatureInstances:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"Feature instance limit reached ({plan.maxFeatureInstances}). Upgrade your plan."
)
featureAccess = FeatureAccess(
userId=userId,
featureInstanceId=instanceId,
enabled=True
# Create new FeatureInstance
featureInterface = getFeatureInterface(db)
featureLabel = featureDef.get("label", {}).get("en", featureCode)
instance = featureInterface.createFeatureInstance(
featureCode=featureCode,
mandateId=mandateId,
label=featureLabel,
enabled=True,
copyTemplateRoles=True,
)
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
featureAccessId = createdAccess.get("id")
userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
if not userRoleId:
db.recordDelete(FeatureAccess, featureAccessId)
logger.error(
f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'"
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=(
f"No '{featureCode}-user' (or equivalent) role found on the shared instance; "
"cannot grant store access. Contact an administrator."
),
)
if not instance:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create feature instance")
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=userRoleId
)
db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
logger.info(
f"User {userId} activated store feature '{featureCode}' "
f"(instance={instanceId}, role={userRoleId})"
)
# Grant FeatureAccess with admin role
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
adminRoleId = None
for ir in instanceRoles:
if "admin" in (ir.get("roleLabel") or "").lower():
adminRoleId = ir.get("id")
break
fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True)
createdFa = db.recordCreate(FeatureAccess, fa.model_dump())
if adminRoleId and createdFa:
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId)
db.recordCreate(FeatureAccessRole, far.model_dump())
# Sync subscription quantity
try:
rootInterface._syncSubscriptionQuantity(mandateId)
except Exception as e:
logger.warning(f"Failed to sync subscription quantity: {e}")
logger.info(f"User {userId} activated '{featureCode}' in mandate {mandateId} (instance={instanceId})")
return {
"featureCode": featureCode,
"mandateId": mandateId,
"instanceId": instanceId,
"featureAccessId": featureAccessId,
"roleId": userRoleId,
"activated": True,
}
@ -309,71 +356,67 @@ def activateStoreFeature(
raise
except Exception as e:
logger.error(f"Error activating store feature '{featureCode}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to activate feature: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.post("/deactivate", response_model=Dict[str, Any])
@limiter.limit("10/minute")
def deactivateStoreFeature(
request: Request,
data: StoreActivateRequest,
data: StoreDeactivateRequest,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Deactivate a store feature for the current user.
Removes FeatureAccess (CASCADE deletes FeatureAccessRole).
User loses access immediately.
Deactivate a store feature. Removes user's FeatureAccess.
Orphan Control: if last user deactivates, FeatureInstance is deleted.
"""
featureCode = data.featureCode
userId = str(context.user.id)
instanceId = data.instanceId
mandateId = data.mandateId
try:
rootInterface = getRootInterface()
db = rootInterface.db
rootMandateId = _getRootMandateId(db)
if not rootMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Root mandate not found"
)
# Verify instance exists in mandate
instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId})
if not instances:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found in mandate")
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
if not sharedInstance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Shared instance for '{featureCode}' not found"
)
# Find user's FeatureAccess
accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
if not accesses:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active access found")
instanceId = sharedInstance.get("id")
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
if not existingAccess:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{featureCode}' is not active"
)
featureAccessId = existingAccess.get("id")
featureAccessId = accesses[0].get("id")
db.recordDelete(FeatureAccess, featureAccessId)
logger.info(f"User {userId} deactivated store feature '{featureCode}' (instance={instanceId})")
# Orphan Control: check if any FeatureAccess remains
remainingAccesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
instanceDeleted = False
if not remainingAccesses:
db.recordDelete(FeatureInstance, instanceId)
instanceDeleted = True
logger.info(f"Orphan Control: deleted instance {instanceId} (no remaining accesses)")
# Sync subscription quantity
try:
rootInterface._syncSubscriptionQuantity(mandateId)
except Exception as e:
logger.warning(f"Failed to sync subscription quantity: {e}")
logger.info(f"User {userId} deactivated instance {instanceId} in mandate {mandateId} (deleted={instanceDeleted})")
return {
"featureCode": featureCode,
"featureCode": data.featureCode,
"mandateId": mandateId,
"instanceId": instanceId,
"deactivated": True,
"instanceDeleted": instanceDeleted,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deactivating store feature '{featureCode}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to deactivate feature: {str(e)}"
)
logger.error(f"Error deactivating store feature: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))

View file

@ -363,6 +363,7 @@ class AgentService:
featureInstanceId=featureInstanceId,
mandateId=mandateId,
workflowHintItems=workflowHintItems,
isSysAdmin=getattr(self.services.user, "isSysAdmin", False),
)
except Exception as e:
logger.debug(f"RAG context not available: {e}")

View file

@ -153,6 +153,9 @@ class AiService:
2. Balance & provider check before AI call
3. billingCallback on aiObjects: records one billing transaction per model call
with exact provider + model name (set before AI call, invoked by _callWithModel)
NEUTRALIZATION: If enabled, prompt text is neutralized before the AI call
and placeholders in the response are rehydrated afterwards.
"""
await self.ensureAiObjectsInitialized()
@ -172,6 +175,11 @@ class AiService:
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
logger.debug(f"Effective allowedProviders for AI request: {effectiveProviders}")
# Neutralize prompt if enabled (before AI call)
_wasNeutralized = False
if self._shouldNeutralize(request):
request, _wasNeutralized = self._neutralizeRequest(request)
# Set billing callback on aiObjects BEFORE the AI call
# This callback is invoked by _callWithModel() after EVERY individual model call
# For parallel content parts (e.g., 200 MB doc), each model call creates its own transaction
@ -187,10 +195,18 @@ class AiService:
finally:
self.aiObjects.billingCallback = None
# Rehydrate neutralization placeholders in response
if _wasNeutralized and response and hasattr(response, 'content') and response.content:
response.content = self._rehydrateResponse(response.content)
return response
async def callAiStream(self, request: AiCallRequest):
"""Streaming variant of callAi. Yields str deltas during generation, then final AiCallResponse."""
"""Streaming variant of callAi. Yields str deltas during generation, then final AiCallResponse.
NEUTRALIZATION: If enabled, prompt text is neutralized before streaming.
Rehydration happens on the final AiCallResponse (not on individual str deltas).
"""
await self.ensureAiObjectsInitialized()
self._preflightBillingCheck()
await self._checkBillingBeforeAiCall()
@ -199,9 +215,17 @@ class AiService:
if effectiveProviders and request.options:
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
# Neutralize prompt if enabled (before streaming)
_wasNeutralized = False
if self._shouldNeutralize(request):
request, _wasNeutralized = self._neutralizeRequest(request)
self.aiObjects.billingCallback = self._createBillingCallback()
try:
async for chunk in self.aiObjects.callWithTextContextStream(request):
# Rehydrate the final AiCallResponse (non-str chunks are the final response)
if _wasNeutralized and not isinstance(chunk, str) and hasattr(chunk, 'content') and chunk.content:
chunk.content = self._rehydrateResponse(chunk.content)
yield chunk
finally:
self.aiObjects.billingCallback = None
@ -511,6 +535,60 @@ detectedIntent-Werte:
return basePrompt
# =========================================================================
# NEUTRALIZATION: Centralized prompt neutralization / response rehydration
# =========================================================================
def _shouldNeutralize(self, request: AiCallRequest) -> bool:
"""Check if this AI request should have neutralization applied.
Only applies to text prompts not embeddings or image processing."""
try:
neutralSvc = self._get_service("neutralization")
if not neutralSvc:
return False
config = neutralSvc.getConfig() if hasattr(neutralSvc, 'getConfig') else None
if not config or not getattr(config, 'enabled', False):
return False
if not request.prompt and not request.messages:
return False
return True
except Exception:
return False
def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool]:
"""Neutralize the prompt text in an AiCallRequest.
Returns (modifiedRequest, wasNeutralized)."""
try:
neutralSvc = self._get_service("neutralization")
if not neutralSvc or not hasattr(neutralSvc, 'processText'):
return request, False
if request.prompt:
result = neutralSvc.processText(request.prompt)
if result and result.get("neutralized_text"):
request.prompt = result["neutralized_text"]
logger.debug("Neutralized prompt in AiCallRequest")
return request, True
return request, False
except Exception as e:
logger.warning(f"Request neutralization failed: {e}")
return request, False
def _rehydrateResponse(self, responseText: str) -> str:
"""Replace neutralization placeholders with original values in AI response."""
if not responseText:
return responseText
try:
neutralSvc = self._get_service("neutralization")
if not neutralSvc or not hasattr(neutralSvc, 'resolveText'):
return responseText
resolved = neutralSvc.resolveText(responseText)
return resolved if resolved else responseText
except Exception as e:
logger.warning(f"Response rehydration failed: {e}")
return responseText
def _preflightBillingCheck(self) -> None:
"""
Pre-flight billing validation - like a 0 CHF credit card authorization check.

View file

@ -110,6 +110,49 @@ class KnowledgeService:
# 2. Chunk text content objects and create embeddings
textObjects = [o for o in contentObjects if o.get("contentType") == "text"]
# Check if file requires neutralization
_shouldNeutralize = False
try:
from modules.datamodels.datamodelFiles import FileItem as _FileItem
_dbComponent = getattr(self._context, 'interfaceDbComponent', None)
_fileRecords = _dbComponent.getRecordset(_FileItem, recordFilter={"id": fileId}) if _dbComponent else []
if _fileRecords:
_fileRecord = _fileRecords[0]
_shouldNeutralize = (
_fileRecord.get("neutralize", False) if isinstance(_fileRecord, dict)
else getattr(_fileRecord, "neutralize", False)
)
except Exception:
pass
if _shouldNeutralize and textObjects:
_neutralizedObjects = []
try:
_neutralSvc = self._getService("neutralization")
except Exception:
_neutralSvc = None
if _neutralSvc:
for _obj in textObjects:
_textContent = (_obj.get("data", "") or "").strip()
if not _textContent:
continue
try:
_neutralResult = _neutralSvc.processText(
_textContent, userId=userId, featureInstanceId=featureInstanceId
)
if _neutralResult and _neutralResult.get("neutralized_text"):
_obj["data"] = _neutralResult["neutralized_text"]
_neutralizedObjects.append(_obj)
else:
logger.warning(f"Neutralization failed for file {fileId}, skipping text object (fail-safe)")
except Exception as e:
logger.warning(f"Neutralization error for file {fileId}: {e}, skipping text object (fail-safe)")
textObjects = _neutralizedObjects
else:
logger.warning(f"Neutralization required for file {fileId} but service unavailable, skipping text indexing")
textObjects = []
if textObjects:
self._knowledgeDb.updateFileStatus(fileId, "embedding")
chunks = _chunkForEmbedding(textObjects, maxTokens=DEFAULT_CHUNK_TOKENS)
@ -155,6 +198,12 @@ class KnowledgeService:
self._knowledgeDb.updateFileStatus(fileId, "indexed")
index.status = "indexed"
if _shouldNeutralize:
try:
index.neutralizationStatus = "completed"
self._knowledgeDb.upsertFileContentIndex(index)
except Exception as e:
logger.debug(f"Could not set neutralizationStatus for file {fileId}: {e}")
logger.info(f"Indexed file {fileId} ({fileName}): {len(contentObjects)} objects, {len(textObjects)} text chunks")
return index
@ -171,6 +220,7 @@ class KnowledgeService:
mandateId: str = "",
contextBudget: int = DEFAULT_CONTEXT_BUDGET,
workflowHintItems: List[Dict[str, Any]] = None,
isSysAdmin: bool = False,
) -> str:
"""Build RAG context for an agent round by searching all layers.
@ -217,13 +267,15 @@ class KnowledgeService:
maxChars=2000,
)
# Layer 1: Instance Layer (user's own documents, highest priority)
# Layer 1: Scope-based document search (personal + instance + mandate + global)
instanceChunks = self._knowledgeDb.semanticSearch(
queryVector=queryVector,
userId=userId,
featureInstanceId=featureInstanceId,
mandateId=mandateId,
limit=15,
minScore=0.65,
isSysAdmin=isSysAdmin,
)
if instanceChunks:
builder.add(priority=1, label="Relevant Documents", items=instanceChunks, maxChars=4000)
@ -271,6 +323,7 @@ class KnowledgeService:
isShared=True,
limit=10,
minScore=0.7,
isSysAdmin=isSysAdmin,
)
if sharedChunks:
builder.add(priority=4, label="Shared Knowledge", items=sharedChunks, maxChars=2000)

View file

@ -172,13 +172,13 @@ async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
)
neutralizedParts.append(neutralizedPart)
else:
# Neutralization failed, use original part
logger.warning(f"Neutralization did not return neutralized_text for part {part.id}")
neutralizedParts.append(part)
# Fail-Safe: neutralization incomplete, skip this part
logger.warning(f"Fail-Safe: Neutralization incomplete for part {part.id}, SKIPPING (not passing original)")
continue
except Exception as e:
logger.error(f"Error neutralizing part {part.id}: {str(e)}")
# On error, use original part
neutralizedParts.append(part)
logger.error(f"Fail-Safe: Error neutralizing part {part.id}, SKIPPING document (not passing original): {str(e)}")
# Fail-Safe: do NOT pass original data to AI
continue
else:
# No data to neutralize, keep original part
neutralizedParts.append(part)

View file

@ -351,7 +351,13 @@ class WorkflowManager:
if documents:
for i, doc in enumerate(documents, 1):
docListText += f"\n{i}. {doc.fileName} ({doc.mimeType}, {doc.fileSize} bytes)"
_userId = getattr(getattr(self.services, 'user', None), 'id', '') or ''
_featureInstanceId = getattr(self.services, 'featureInstanceId', '') or ''
_promptForAnalysis, _wasNeutralized, _mappingId = await self._neutralizePromptIfRequired(
userPrompt, userId=_userId, featureInstanceId=_featureInstanceId
)
analysisPrompt = f"""You are an input analyzer. From the user's message, perform ALL of the following in one pass:
1. detectedLanguage: Detect ISO 639-1 language code (e.g., de, en, fr, it)
@ -401,7 +407,7 @@ Return ONLY JSON (no markdown) with this exact structure:
The following is the user's original input message. Analyze intent, normalize the request, and determine complexity:
################ USER INPUT START #################
{userPrompt.replace('{', '{{').replace('}', '}}') if userPrompt else ''}
{_promptForAnalysis.replace('{', '{{').replace('}', '}}') if _promptForAnalysis else ''}
################ USER INPUT FINISH #################
"""
@ -419,6 +425,12 @@ The following is the user's original input message. Analyze intent, normalize th
jsonEnd = aiResponse.rfind('}') + 1 if aiResponse else 0
if jsonStart != -1 and jsonEnd > jsonStart:
result = json.loads(aiResponse[jsonStart:jsonEnd])
if _wasNeutralized:
for _field in ('normalizedRequest', 'intent', 'workflowName'):
if _field in result and result[_field]:
result[_field] = await self._rehydrateResponseIfNeeded(
result[_field], True, userId=_userId, featureInstanceId=_featureInstanceId
)
return result
else:
logger.warning("Could not parse combined analysis response, using defaults")
@ -1353,6 +1365,38 @@ The following is the user's original input message. Analyze intent, normalize th
"""Set user language for the service center"""
self.services.user.language = language
async def _neutralizePromptIfRequired(self, prompt: str, userId: str, featureInstanceId: str) -> tuple:
"""Neutralize prompt text if the workflow context requires it.
Returns (processedPrompt, wasNeutralized, mappingId)."""
try:
_neutralSvc = getattr(self.services, 'neutralization', None)
if not _neutralSvc:
return prompt, False, None
_config = _neutralSvc.getConfig() if hasattr(_neutralSvc, 'getConfig') else None
if not _config or not getattr(_config, 'enabled', False):
return prompt, False, None
_result = _neutralSvc.processText(prompt, userId=userId, featureInstanceId=featureInstanceId)
if _result and _result.get("neutralized_text"):
return _result["neutralized_text"], True, _result.get("mappingId")
return prompt, False, None
except Exception as e:
logger.warning(f"Prompt neutralization failed: {e}")
return prompt, False, None
async def _rehydrateResponseIfNeeded(self, response: str, wasNeutralized: bool, userId: str, featureInstanceId: str) -> str:
"""Replace placeholders in AI response with original values."""
if not wasNeutralized or not response:
return response
try:
_neutralSvc = getattr(self.services, 'neutralization', None)
if not _neutralSvc:
return response
_rehydrated = _neutralSvc.resolveText(response, userId=userId, featureInstanceId=featureInstanceId)
return _rehydrated if _rehydrated else response
except Exception as e:
logger.warning(f"Response re-hydration failed: {e}")
return response
async def _neutralizeContentIfEnabled(self, contentBytes: bytes, mimeType: str) -> bytes:
"""Neutralize content if neutralization is enabled in user settings"""
try:

View file

@ -0,0 +1,323 @@
"""
Basic verification tests for Phase 1-3 implementation.
Run with: python tests/test_phase123_basic.py
Requires: gateway running on localhost:8000
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
print("=" * 60)
print("PHASE 1-3 BASIC VERIFICATION")
print("=" * 60)
errors = []
passes = []
def _check(label, condition, detail=""):
if condition:
passes.append(label)
print(f" [PASS] {label}")
else:
errors.append(f"{label}: {detail}")
print(f" [FAIL] {label}{detail}")
# ── Phase 1: Data Models ──────────────────────────────────────────────────────
print("\n--- Phase 1: Data Models ---")
try:
from modules.datamodels.datamodelUam import Mandate, MandateType
_check("MandateType Enum exists", hasattr(MandateType, "SYSTEM"))
_check("MandateType values", set(MandateType) == {MandateType.SYSTEM, MandateType.PERSONAL, MandateType.COMPANY})
m = Mandate(name="test", label="test", mandateType="personal")
_check("Mandate has mandateType field", hasattr(m, "mandateType"))
_check("Mandate mandateType coercion", m.mandateType == MandateType.PERSONAL)
except Exception as e:
errors.append(f"Phase 1 DataModel: {e}")
print(f" [FAIL] Phase 1 DataModel import: {e}")
try:
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS, SubscriptionPlan
_check("PENDING status exists", hasattr(SubscriptionStatusEnum, "PENDING"))
_check("BUILTIN_PLANS has TRIAL_7D", "TRIAL_7D" in BUILTIN_PLANS)
trial = BUILTIN_PLANS["TRIAL_7D"]
_check("TRIAL_7D has maxDataVolumeMB", hasattr(trial, "maxDataVolumeMB"))
_check("TRIAL_7D maxDataVolumeMB=500", trial.maxDataVolumeMB == 500)
except Exception as e:
errors.append(f"Phase 1 Subscription: {e}")
print(f" [FAIL] Phase 1 Subscription: {e}")
# ── Phase 2: Scope Fields ─────────────────────────────────────────────────────
print("\n--- Phase 2: Scope Fields on Models ---")
try:
from modules.datamodels.datamodelFiles import FileItem
fi = FileItem(fileName="test.txt", mimeType="text/plain", fileHash="abc", fileSize=100)
_check("FileItem has scope field", hasattr(fi, "scope"))
_check("FileItem scope default=personal", fi.scope == "personal")
_check("FileItem has neutralize field", hasattr(fi, "neutralize"))
_check("FileItem neutralize default=False", fi.neutralize == False)
except Exception as e:
errors.append(f"Phase 2 FileItem: {e}")
print(f" [FAIL] Phase 2 FileItem: {e}")
try:
from modules.datamodels.datamodelDataSource import DataSource
ds = DataSource(connectionId="c1", sourceType="sharepoint", path="/test", label="Test")
_check("DataSource has scope field", hasattr(ds, "scope"))
_check("DataSource scope default=personal", ds.scope == "personal")
_check("DataSource has neutralize field", hasattr(ds, "neutralize"))
_check("DataSource neutralize default=False", ds.neutralize == False)
except Exception as e:
errors.append(f"Phase 2 DataSource: {e}")
print(f" [FAIL] Phase 2 DataSource: {e}")
try:
from modules.datamodels.datamodelKnowledge import FileContentIndex
fci = FileContentIndex(userId="u1", fileName="test.txt", mimeType="text/plain")
_check("FileContentIndex has scope field", hasattr(fci, "scope"))
_check("FileContentIndex scope default=personal", fci.scope == "personal")
_check("FileContentIndex has neutralizationStatus", hasattr(fci, "neutralizationStatus"))
_check("FileContentIndex neutralizationStatus default=None", fci.neutralizationStatus is None)
except Exception as e:
errors.append(f"Phase 2 FileContentIndex: {e}")
print(f" [FAIL] Phase 2 FileContentIndex: {e}")
# ── Phase 2: RAG Scope Filtering ──────────────────────────────────────────────
print("\n--- Phase 2: RAG Scope Logic ---")
try:
from modules.interfaces.interfaceDbKnowledge import KnowledgeObjects
_check("KnowledgeObjects has _getScopedFileIds", hasattr(KnowledgeObjects, "_getScopedFileIds"))
_check("KnowledgeObjects has _buildScopeFilter", hasattr(KnowledgeObjects, "_buildScopeFilter"))
import inspect
sig = inspect.signature(KnowledgeObjects._getScopedFileIds)
params = list(sig.parameters.keys())
_check("_getScopedFileIds has isSysAdmin param", "isSysAdmin" in params)
sig2 = inspect.signature(KnowledgeObjects.semanticSearch)
params2 = list(sig2.parameters.keys())
_check("semanticSearch has scope param", "scope" in params2)
_check("semanticSearch has isSysAdmin param", "isSysAdmin" in params2)
except Exception as e:
errors.append(f"Phase 2 RAG: {e}")
print(f" [FAIL] Phase 2 RAG: {e}")
# ── Phase 3: Neutralization Methods ───────────────────────────────────────────
print("\n--- Phase 3: Neutralization Integration ---")
try:
from modules.workflows.workflowManager import WorkflowManager
_check("WorkflowManager has _neutralizePromptIfRequired", hasattr(WorkflowManager, "_neutralizePromptIfRequired"))
_check("WorkflowManager has _rehydrateResponseIfNeeded", hasattr(WorkflowManager, "_rehydrateResponseIfNeeded"))
import inspect
sig_n = inspect.signature(WorkflowManager._neutralizePromptIfRequired)
_check("_neutralizePromptIfRequired is async", inspect.iscoroutinefunction(WorkflowManager._neutralizePromptIfRequired))
sig_r = inspect.signature(WorkflowManager._rehydrateResponseIfNeeded)
_check("_rehydrateResponseIfNeeded is async", inspect.iscoroutinefunction(WorkflowManager._rehydrateResponseIfNeeded))
except Exception as e:
errors.append(f"Phase 3 WorkflowManager: {e}")
print(f" [FAIL] Phase 3 WorkflowManager: {e}")
# ── Phase 3: Fail-Safe Logic ──────────────────────────────────────────────────
print("\n--- Phase 3: Fail-Safe Logic ---")
try:
import ast
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "workflows", "methods", "methodContext", "actions", "neutralizeData.py"), "r") as f:
source = f.read()
_check("neutralizeData.py has 'SKIPPING' fail-safe", "SKIPPING" in source)
_check("neutralizeData.py has 'do NOT pass original' comment", "do NOT pass original" in source.lower() or "not passing original" in source.lower())
_check("neutralizeData.py uses continue for skip", "continue" in source)
except Exception as e:
errors.append(f"Phase 3 Fail-Safe: {e}")
print(f" [FAIL] Phase 3 Fail-Safe: {e}")
# ── Phase 2: Route Endpoints ──────────────────────────────────────────────────
print("\n--- Phase 2: API Endpoints ---")
try:
import ast
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "routes", "routeDataFiles.py"), "r") as f:
source = f.read()
_check("routeDataFiles has PATCH scope endpoint", "updateFileScope" in source)
_check("routeDataFiles has PATCH neutralize endpoint", "updateFileNeutralize" in source)
_check("routeDataFiles checks global sysAdmin", "hasSysAdminRole" in source or "sysadmin" in source.lower())
except Exception as e:
errors.append(f"Phase 2 Routes: {e}")
print(f" [FAIL] Phase 2 Routes: {e}")
# ── Phase 1: Store Endpoints ──────────────────────────────────────────────────
print("\n--- Phase 1: Store Endpoints ---")
try:
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "routes", "routeStore.py"), "r") as f:
source = f.read()
_check("routeStore has listUserMandates", "listUserMandates" in source or "list_user_mandates" in source)
_check("routeStore has getSubscriptionInfo", "getSubscriptionInfo" in source or "get_subscription_info" in source)
_check("routeStore has orphan control", "orphan" in source.lower() or "last" in source.lower())
except Exception as e:
errors.append(f"Phase 1 Store: {e}")
print(f" [FAIL] Phase 1 Store: {e}")
# ── Phase 1: Provisioning ─────────────────────────────────────────────────────
print("\n--- Phase 1: Provisioning ---")
try:
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "interfaces", "interfaceDbApp.py"), "r") as f:
source = f.read()
_check("interfaceDbApp has _provisionMandateForUser", "_provisionMandateForUser" in source)
_check("interfaceDbApp has _activatePendingSubscriptions", "_activatePendingSubscriptions" in source)
_check("interfaceDbApp has deleteMandate cascade", "deleteMandate" in source and "cascade" in source.lower())
except Exception as e:
errors.append(f"Phase 1 Provisioning: {e}")
print(f" [FAIL] Phase 1 Provisioning: {e}")
# ── Phase 1: Registration Routes ──────────────────────────────────────────────
print("\n--- Phase 1: Registration ---")
try:
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "routes", "routeSecurityLocal.py"), "r") as f:
source = f.read()
_check("routeSecurityLocal has registrationType", "registrationType" in source)
_check("routeSecurityLocal has companyName", "companyName" in source)
_check("routeSecurityLocal has onboarding endpoint", "onboarding" in source)
except Exception as e:
errors.append(f"Phase 1 Registration: {e}")
print(f" [FAIL] Phase 1 Registration: {e}")
# ── Phase 1: Migration ────────────────────────────────────────────────────────
print("\n--- Phase 1: Migration ---")
try:
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "migration", "migrateRootUsers.py"), "r") as f:
source = f.read()
_check("Migration script exists", True)
_check("Migration has _isMigrationCompleted", "_isMigrationCompleted" in source)
_check("Migration has migrateRootUsers", "migrateRootUsers" in source)
except Exception as e:
errors.append(f"Phase 1 Migration: {e}")
print(f" [FAIL] Phase 1 Migration: {e}")
# ── Fix 1: OnboardingWizard Integration ────────────────────────────────────────
print("\n--- Fix 1: OnboardingWizard Integration ---")
try:
loginPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"..", "frontend_nyla", "src", "pages", "Login.tsx")
with open(loginPath, "r", encoding="utf-8") as f:
source = f.read()
_check("Login.tsx imports OnboardingWizard", "OnboardingWizard" in source)
_check("Login.tsx has showOnboardingWizard state", "showOnboardingWizard" in source)
_check("Login.tsx checks isNewUser", "isNewUser" in source)
except Exception as e:
errors.append(f"Fix 1: {e}")
print(f" [FAIL] Fix 1: {e}")
# ── Fix 2: CommCoach UDB Integration ──────────────────────────────────────────
print("\n--- Fix 2: CommCoach UDB Integration ---")
try:
dossierPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"..", "frontend_nyla", "src", "pages", "views", "commcoach", "CommcoachDossierView.tsx")
with open(dossierPath, "r", encoding="utf-8") as f:
source = f.read()
_check("CommCoach imports UnifiedDataBar", "UnifiedDataBar" in source)
_check("CommCoach imports FilesTab", "FilesTab" in source)
_check("CommCoach no longer imports getDocumentsApi", "getDocumentsApi" not in source)
_check("CommCoach has UDB sidebar", "udbSidebar" in source or "UnifiedDataBar" in source)
except Exception as e:
errors.append(f"Fix 2: {e}")
print(f" [FAIL] Fix 2: {e}")
# ── Fix 3: Neutralization Backend Endpoints ───────────────────────────────────
print("\n--- Fix 3: Neutralization Backend Endpoints ---")
try:
routePath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "features", "neutralization", "routeFeatureNeutralizer.py")
with open(routePath, "r") as f:
source = f.read()
_check("Neutralization has deleteAttribute endpoint", "deleteAttribute" in source or "delete_attribute" in source)
_check("Neutralization has retrigger endpoint", "retrigger" in source)
_check("Neutralization has single attribute delete", "single" in source or "attributeId" in source)
except Exception as e:
errors.append(f"Fix 3: {e}")
print(f" [FAIL] Fix 3: {e}")
# ── Fix 4: Central AI Neutralization ──────────────────────────────────────────
print("\n--- Fix 4: Central AI Neutralization ---")
try:
aiPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "serviceCenter", "services", "serviceAi", "mainServiceAi.py")
with open(aiPath, "r") as f:
source = f.read()
_check("AiService has _shouldNeutralize", "_shouldNeutralize" in source)
_check("AiService has _neutralizeRequest", "_neutralizeRequest" in source)
_check("AiService has _rehydrateResponse", "_rehydrateResponse" in source)
_check("callAi uses neutralization", "_shouldNeutralize" in source and "_neutralizeRequest" in source)
except Exception as e:
errors.append(f"Fix 4: {e}")
print(f" [FAIL] Fix 4: {e}")
# ── Fix 5: Voice Settings User Level ──────────────────────────────────────────
print("\n--- Fix 5: Voice Settings User Level ---")
try:
from modules.datamodels.datamodelUam import UserVoicePreferences
uvp = UserVoicePreferences(userId="u1")
_check("UserVoicePreferences model exists", True)
_check("UserVoicePreferences has sttLanguage", hasattr(uvp, "sttLanguage"))
_check("UserVoicePreferences default sttLanguage=de-DE", uvp.sttLanguage == "de-DE")
_check("UserVoicePreferences has ttsVoice", hasattr(uvp, "ttsVoice"))
except Exception as e:
errors.append(f"Fix 5: {e}")
print(f" [FAIL] Fix 5: {e}")
try:
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "routes", "routeSecurityLocal.py"), "r") as f:
source = f.read()
_check("Voice preferences GET endpoint", "voice-preferences" in source and "getVoicePreferences" in source)
_check("Voice preferences PUT endpoint", "updateVoicePreferences" in source)
except Exception as e:
errors.append(f"Fix 5 Routes: {e}")
print(f" [FAIL] Fix 5 Routes: {e}")
# ── Fix 6: RAG mandate-wide scope ─────────────────────────────────────────────
print("\n--- Fix 6: RAG mandate-wide scope ---")
try:
knowledgePath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "serviceCenter", "services", "serviceKnowledge", "mainServiceKnowledge.py")
with open(knowledgePath, "r") as f:
source = f.read()
_check("buildAgentContext passes mandateId to semanticSearch", "mandateId=mandateId" in source)
_check("buildAgentContext has isSysAdmin param", "isSysAdmin" in source)
except Exception as e:
errors.append(f"Fix 6: {e}")
print(f" [FAIL] Fix 6: {e}")
# ── Summary ───────────────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print(f"RESULTS: {len(passes)} passed, {len(errors)} failed")
print("=" * 60)
if errors:
print("\nFAILURES:")
for e in errors:
print(f" - {e}")
sys.exit(1)
else:
print("\nALL CHECKS PASSED!")
sys.exit(0)