streamlined billing incl ai and storage budget
This commit is contained in:
parent
c12a75f87f
commit
3ac25a269a
21 changed files with 740 additions and 175 deletions
3
app.py
3
app.py
|
|
@ -566,6 +566,9 @@ app.include_router(googleRouter)
|
|||
from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter
|
||||
app.include_router(voiceGoogleRouter)
|
||||
|
||||
from modules.routes.routeVoiceUser import router as voiceUserRouter
|
||||
app.include_router(voiceUserRouter)
|
||||
|
||||
from modules.routes.routeSecurityAdmin import router as adminSecurityRouter
|
||||
app.include_router(adminSecurityRouter)
|
||||
|
||||
|
|
|
|||
|
|
@ -158,12 +158,17 @@ def _parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context:
|
|||
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
|
||||
|
||||
|
||||
# Legacy system columns (underscore-prefixed internal names) -> PowerOn sys* columns.
|
||||
_LEGACY_UNDERSCORE_TO_SYS: Tuple[Tuple[str, str], ...] = (
|
||||
# Legacy column names (historical _* internal names and old camelCase audit fields) -> PowerOn sys* columns.
|
||||
# Order matters: more specific / underscore names first; first successful copy wins per cell via IS NULL on target.
|
||||
_LEGACY_FIELD_TO_SYS: Tuple[Tuple[str, str], ...] = (
|
||||
("_createdAt", "sysCreatedAt"),
|
||||
("_createdBy", "sysCreatedBy"),
|
||||
("_modifiedAt", "sysModifiedAt"),
|
||||
("_modifiedBy", "sysModifiedBy"),
|
||||
("createdAt", "sysCreatedAt"),
|
||||
("creationDate", "sysCreatedAt"),
|
||||
("updatedAt", "sysModifiedAt"),
|
||||
("lastModified", "sysModifiedAt"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -454,9 +459,9 @@ class DatabaseConnector:
|
|||
def migrateLegacyUnderscoreSysColumns(self) -> int:
|
||||
"""
|
||||
Scan all public base tables on this connection's database. Where both a legacy
|
||||
_createdAt / _createdBy / _modifiedAt / _modifiedBy column (any case) and the
|
||||
matching sys* column exist, copy into sys* rows where sys* IS NULL and legacy IS NOT NULL.
|
||||
Idempotent; safe to run on every bootstrap.
|
||||
source column (any case: _createdAt, createdAt, creationDate, …) and the matching
|
||||
sys* column exist, UPDATE sys* from legacy where sys* IS NULL AND legacy IS NOT NULL.
|
||||
Idempotent; run after schema adds sys* columns (see _ensureTableExists).
|
||||
"""
|
||||
self._ensure_connection()
|
||||
total = 0
|
||||
|
|
@ -466,7 +471,7 @@ class DatabaseConnector:
|
|||
for table in tableNames:
|
||||
with self.connection.cursor() as cursor:
|
||||
cols = _listTableColumnNames(cursor, table)
|
||||
for legacyLogical, sysLogical in _LEGACY_UNDERSCORE_TO_SYS:
|
||||
for legacyLogical, sysLogical in _LEGACY_FIELD_TO_SYS:
|
||||
src = _resolveColumnCaseInsensitive(cols, legacyLogical)
|
||||
tgt = _resolveColumnCaseInsensitive(cols, sysLogical)
|
||||
if not src or not tgt or src == tgt:
|
||||
|
|
@ -629,6 +634,7 @@ class DatabaseConnector:
|
|||
|
||||
try:
|
||||
self._ensure_connection()
|
||||
schemaTouched = False
|
||||
|
||||
with self.connection.cursor() as cursor:
|
||||
# Check if table exists by querying information_schema with case-insensitive search
|
||||
|
|
@ -647,6 +653,7 @@ class DatabaseConnector:
|
|||
logger.info(
|
||||
f"Created table '{table}' with columns from Pydantic model"
|
||||
)
|
||||
schemaTouched = True
|
||||
else:
|
||||
# Table exists: ensure all columns from model are present (simple additive migration)
|
||||
try:
|
||||
|
|
@ -680,6 +687,7 @@ class DatabaseConnector:
|
|||
logger.info(
|
||||
f"Added missing column '{col}' ({sql_type}) to '{table}'"
|
||||
)
|
||||
schemaTouched = True
|
||||
except Exception as add_err:
|
||||
logger.warning(
|
||||
f"Could not add column '{col}' to '{table}': {add_err}"
|
||||
|
|
@ -690,6 +698,23 @@ class DatabaseConnector:
|
|||
)
|
||||
|
||||
self.connection.commit()
|
||||
if schemaTouched:
|
||||
try:
|
||||
n = self.migrateLegacyUnderscoreSysColumns()
|
||||
if n:
|
||||
logger.info(
|
||||
"After schema change on %s.%s: legacy -> sys* migration wrote %s cell(s)",
|
||||
self.dbDatabase,
|
||||
table,
|
||||
n,
|
||||
)
|
||||
except Exception as mig_err:
|
||||
logger.error(
|
||||
"migrateLegacyUnderscoreSysColumns failed after schema change %s.%s: %s",
|
||||
self.dbDatabase,
|
||||
table,
|
||||
mig_err,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error ensuring table {table} exists: {e}")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ from modules.shared.configuration import APP_CONFIG
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require model_name + prompt.
|
||||
_GEMINI_TTS_DEFAULT_MODEL = "gemini-2.5-flash-tts"
|
||||
_GEMINI_TTS_NEUTRAL_PROMPT = "Say the following"
|
||||
|
||||
|
||||
class ConnectorGoogleSpeech:
|
||||
"""
|
||||
Google Cloud Speech-to-Text and Translation connector.
|
||||
|
|
@ -902,6 +907,13 @@ class ConnectorGoogleSpeech:
|
|||
"error": f"Validation error: {e}"
|
||||
}
|
||||
|
||||
def _isGeminiTtsSpeakerVoiceName(self, voiceName: str) -> bool:
|
||||
"""True when voice name is a Gemini-TTS speaker id (no BCP-47 prefix like en-US-...)."""
|
||||
if not voiceName or not isinstance(voiceName, str):
|
||||
return False
|
||||
stripped = voiceName.strip()
|
||||
return bool(stripped) and "-" not in stripped
|
||||
|
||||
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert text to speech using Google Cloud Text-to-Speech.
|
||||
|
|
@ -917,9 +929,6 @@ class ConnectorGoogleSpeech:
|
|||
try:
|
||||
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
|
||||
|
||||
# Set up the synthesis input
|
||||
synthesisInput = texttospeech.SynthesisInput(text=text)
|
||||
|
||||
# Build the voice request
|
||||
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
|
||||
|
||||
|
|
@ -931,11 +940,24 @@ class ConnectorGoogleSpeech:
|
|||
|
||||
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
|
||||
|
||||
voice = texttospeech.VoiceSelectionParams(
|
||||
language_code=languageCode,
|
||||
name=selectedVoice,
|
||||
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL
|
||||
)
|
||||
if self._isGeminiTtsSpeakerVoiceName(selectedVoice):
|
||||
synthesisInput = texttospeech.SynthesisInput(
|
||||
text=text,
|
||||
prompt=_GEMINI_TTS_NEUTRAL_PROMPT,
|
||||
)
|
||||
voice = texttospeech.VoiceSelectionParams(
|
||||
language_code=languageCode,
|
||||
name=selectedVoice,
|
||||
model_name=_GEMINI_TTS_DEFAULT_MODEL,
|
||||
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
|
||||
)
|
||||
else:
|
||||
synthesisInput = texttospeech.SynthesisInput(text=text)
|
||||
voice = texttospeech.VoiceSelectionParams(
|
||||
language_code=languageCode,
|
||||
name=selectedVoice,
|
||||
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
|
||||
)
|
||||
|
||||
# Select the type of audio file to return
|
||||
audioConfig = texttospeech.AudioConfig(
|
||||
|
|
@ -1059,7 +1081,8 @@ class ConnectorGoogleSpeech:
|
|||
"language_codes": list(voice.language_codes) if voice.language_codes else [],
|
||||
"gender": gender,
|
||||
"ssml_gender": voice.ssml_gender.name if voice.ssml_gender else "NEUTRAL",
|
||||
"natural_sample_rate_hertz": voice.natural_sample_rate_hertz
|
||||
"natural_sample_rate_hertz": voice.natural_sample_rate_hertz,
|
||||
"geminiTts": self._isGeminiTtsSpeakerVoiceName(voice.name or ""),
|
||||
}
|
||||
|
||||
# Include any additional fields if available from Google API
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ from modules.datamodels.datamodelBase import PowerOnModel
|
|||
from modules.shared.attributeUtils import registerModelLabels
|
||||
import uuid
|
||||
|
||||
# End-customer price for storage above plan-included volume (CHF per GB per month).
|
||||
STORAGE_PRICE_PER_GB_CHF = 0.50
|
||||
|
||||
|
||||
class TransactionTypeEnum(str, Enum):
|
||||
"""Transaction types for billing."""
|
||||
|
|
@ -24,6 +27,7 @@ class ReferenceTypeEnum(str, Enum):
|
|||
PAYMENT = "PAYMENT" # Payment/top-up
|
||||
ADMIN = "ADMIN" # Admin adjustment
|
||||
SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
|
||||
STORAGE = "STORAGE" # Metered storage overage (prepay pool)
|
||||
|
||||
|
||||
class PeriodTypeEnum(str, Enum):
|
||||
|
|
@ -137,6 +141,18 @@ class BillingSettings(BaseModel):
|
|||
)
|
||||
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
|
||||
|
||||
# Storage overage (high-watermark within subscription period; resets on new period)
|
||||
storageHighWatermarkMB: float = Field(
|
||||
default=0.0, description="Peak indexed data volume MB this billing period"
|
||||
)
|
||||
storagePeriodStartAt: Optional[datetime] = Field(
|
||||
None, description="Subscription billing period start used for storage reset"
|
||||
)
|
||||
storageBilledUpToMB: float = Field(
|
||||
default=0.0,
|
||||
description="Overage MB already debited this period (above plan-included volume)",
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"BillingSettings",
|
||||
|
|
@ -154,6 +170,12 @@ registerModelLabels(
|
|||
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
|
||||
},
|
||||
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
||||
"storageHighWatermarkMB": {"en": "Storage peak (MB)", "de": "Speicher-Peak (MB)"},
|
||||
"storagePeriodStartAt": {"en": "Storage period start", "de": "Speicher-Periodenbeginn"},
|
||||
"storageBilledUpToMB": {
|
||||
"en": "Storage billed overage (MB)",
|
||||
"de": "Speicher abgerechneter Überhang (MB)",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
|||
billingPeriod=BillingPeriodEnum.MONTHLY,
|
||||
pricePerUserCHF=90.0,
|
||||
pricePerFeatureInstanceCHF=150.0,
|
||||
maxDataVolumeMB=10240,
|
||||
maxDataVolumeMB=1024,
|
||||
budgetAiCHF=10.0,
|
||||
),
|
||||
"STANDARD_YEARLY": SubscriptionPlan(
|
||||
|
|
@ -232,7 +232,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
|||
billingPeriod=BillingPeriodEnum.YEARLY,
|
||||
pricePerUserCHF=1080.0,
|
||||
pricePerFeatureInstanceCHF=1800.0,
|
||||
maxDataVolumeMB=10240,
|
||||
maxDataVolumeMB=1024,
|
||||
budgetAiCHF=120.0,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,27 @@ class _PendingEditsStore:
|
|||
_pendingEditsStore = _PendingEditsStore()
|
||||
|
||||
|
||||
def _workspaceBillingFeatureCode(user, mandateId: Optional[str], instanceId: str) -> Optional[str]:
|
||||
"""Resolve FeatureInstance.featureCode for billing/UI when workflow is not on ServiceCenterContext."""
|
||||
if not instanceId or not str(instanceId).strip():
|
||||
return None
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||
|
||||
appIf = getAppInterface(user, mandateId=mandateId or None)
|
||||
inst = appIf.getFeatureInstance(str(instanceId).strip())
|
||||
if not inst:
|
||||
return None
|
||||
if isinstance(inst, dict):
|
||||
code = inst.get("featureCode")
|
||||
else:
|
||||
code = getattr(inst, "featureCode", None)
|
||||
return str(code).strip() if code else None
|
||||
except Exception as e:
|
||||
logger.debug("Workspace: feature code lookup failed for instance %s: %s", instanceId, e)
|
||||
return None
|
||||
|
||||
|
||||
class WorkspaceInputRequest(BaseModel):
|
||||
"""Prompt input for the unified workspace."""
|
||||
prompt: str = Field(description="User prompt text")
|
||||
|
|
@ -546,11 +567,13 @@ async def streamWorkspaceStart(
|
|||
from modules.serviceCenter import getService
|
||||
from modules.serviceCenter.context import ServiceCenterContext
|
||||
|
||||
wsBillingFeatureCode = _workspaceBillingFeatureCode(context.user, mandateId or "", instanceId)
|
||||
svcCtx = ServiceCenterContext(
|
||||
user=context.user,
|
||||
mandate_id=mandateId or "",
|
||||
feature_instance_id=instanceId,
|
||||
workflow_id=workflowId,
|
||||
feature_code=wsBillingFeatureCode,
|
||||
)
|
||||
chatSvc = getService("chat", svcCtx)
|
||||
attachmentLabel = _buildWorkspaceAttachmentLabel(
|
||||
|
|
@ -590,6 +613,7 @@ async def streamWorkspaceStart(
|
|||
instanceConfig=instanceConfig,
|
||||
allowedProviders=userInput.allowedProviders,
|
||||
requireNeutralization=userInput.requireNeutralization,
|
||||
billingFeatureCode=wsBillingFeatureCode,
|
||||
)
|
||||
)
|
||||
eventManager.register_agent_task(queueId, agentTask)
|
||||
|
|
@ -646,6 +670,7 @@ async def _runWorkspaceAgent(
|
|||
instanceConfig: Dict[str, Any] = None,
|
||||
allowedProviders: List[str] = None,
|
||||
requireNeutralization: Optional[bool] = None,
|
||||
billingFeatureCode: Optional[str] = None,
|
||||
):
|
||||
"""Run the serviceAgent loop and forward events to the SSE queue."""
|
||||
try:
|
||||
|
|
@ -656,6 +681,7 @@ async def _runWorkspaceAgent(
|
|||
mandate_id=mandateId,
|
||||
feature_instance_id=instanceId,
|
||||
workflow_id=workflowId,
|
||||
feature_code=billingFeatureCode,
|
||||
)
|
||||
agentService = getService("agent", ctx)
|
||||
chatService = getService("chat", ctx)
|
||||
|
|
|
|||
|
|
@ -38,15 +38,23 @@ pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
|
|||
# Cache für Role-IDs (roleLabel -> roleId)
|
||||
_roleIdCache: Dict[str, str] = {}
|
||||
|
||||
# PowerOn logical databases to scan (same set as gateway/scripts/script_db_export_migration.py).
|
||||
# PowerOn logical databases to scan (same set as gateway/scripts/script_db_export_migration.py ALL_DATABASES).
|
||||
_POWERON_DATABASE_NAMES: Tuple[str, ...] = (
|
||||
"poweron_app",
|
||||
"poweron_automation",
|
||||
"poweron_automation2",
|
||||
"poweron_billing",
|
||||
"poweron_chat",
|
||||
"poweron_chatbot",
|
||||
"poweron_commcoach",
|
||||
"poweron_knowledge",
|
||||
"poweron_management",
|
||||
"poweron_neutralization",
|
||||
"poweron_realestate",
|
||||
"poweron_teamsbot",
|
||||
"poweron_test",
|
||||
"poweron_trustee",
|
||||
"poweron_automation",
|
||||
"poweron_workspace",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -60,6 +68,7 @@ def _configPrefixForPoweronDatabase(dbName: str) -> str:
|
|||
"poweron_trustee": "DB_TRUSTEE",
|
||||
# Same as initAutomationTemplates: default DB_* (not a separate DB_AUTOMATION_* prefix).
|
||||
"poweron_automation": "DB",
|
||||
"poweron_billing": "DB",
|
||||
}.get(dbName, "DB")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ All billing data is stored in the poweron_billing database.
|
|||
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
import uuid
|
||||
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
|
|
@ -29,11 +29,44 @@ from modules.datamodels.datamodelBilling import (
|
|||
PeriodTypeEnum,
|
||||
BillingBalanceResponse,
|
||||
BillingCheckResult,
|
||||
STORAGE_PRICE_PER_GB_CHF,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _logBillingTransactionsMissingSysCreatedAt(rows: List[Dict[str, Any]], context: str) -> None:
|
||||
"""Log ERROR when sysCreatedAt is missing; does not raise."""
|
||||
missingIds = [r.get("id") for r in rows if r.get("sysCreatedAt") is None]
|
||||
if not missingIds:
|
||||
return
|
||||
cap = 40
|
||||
sample = missingIds[:cap]
|
||||
suffix = f"; ... (+{len(missingIds) - cap} more)" if len(missingIds) > cap else ""
|
||||
logger.error(
|
||||
"BillingTransaction missing sysCreatedAt (%s): count=%s; transactionIds=%s%s",
|
||||
context,
|
||||
len(missingIds),
|
||||
sample,
|
||||
suffix,
|
||||
)
|
||||
|
||||
|
||||
def _numericSysCreatedAtForSort(row: Dict[str, Any]) -> float:
|
||||
v = row["sysCreatedAt"]
|
||||
if isinstance(v, datetime):
|
||||
return v.timestamp()
|
||||
return float(v)
|
||||
|
||||
|
||||
def _sortBillingTransactionsBySysCreatedAtDesc(rows: List[Dict[str, Any]], context: str) -> None:
|
||||
_logBillingTransactionsMissingSysCreatedAt(rows, context)
|
||||
valid = [r for r in rows if r.get("sysCreatedAt") is not None]
|
||||
invalid = [r for r in rows if r.get("sysCreatedAt") is None]
|
||||
valid.sort(key=_numericSysCreatedAtForSort, reverse=True)
|
||||
rows[:] = valid + invalid
|
||||
|
||||
|
||||
def _getAppDatabaseConnector() -> DatabaseConnector:
|
||||
"""App DB connector (same config as UserMandate reads in this module)."""
|
||||
return DatabaseConnector(
|
||||
|
|
@ -553,6 +586,17 @@ class BillingObjects:
|
|||
|
||||
# Create transaction record (always on transaction.accountId for audit)
|
||||
transactionDict = transaction.model_dump(exclude_none=True)
|
||||
ts = getUtcTimestamp()
|
||||
uid = str(self.userId) if self.userId else None
|
||||
if transactionDict.get("sysCreatedAt") is None:
|
||||
transactionDict["sysCreatedAt"] = ts
|
||||
if transactionDict.get("sysModifiedAt") is None:
|
||||
transactionDict["sysModifiedAt"] = ts
|
||||
if uid:
|
||||
if transactionDict.get("sysCreatedBy") is None:
|
||||
transactionDict["sysCreatedBy"] = uid
|
||||
if transactionDict.get("sysModifiedBy") is None:
|
||||
transactionDict["sysModifiedBy"] = uid
|
||||
created = self.db.recordCreate(BillingTransaction, transactionDict)
|
||||
|
||||
# Update balance on the target account
|
||||
|
|
@ -597,6 +641,10 @@ class BillingObjects:
|
|||
pagination=pagination,
|
||||
recordFilter=recordFilter
|
||||
)
|
||||
_logBillingTransactionsMissingSysCreatedAt(
|
||||
result["items"],
|
||||
"getTransactions(accountId) paginated",
|
||||
)
|
||||
return PaginatedResult(
|
||||
items=result["items"],
|
||||
totalItems=result["totalItems"],
|
||||
|
|
@ -619,7 +667,7 @@ class BillingObjects:
|
|||
filtered.append(t)
|
||||
results = filtered
|
||||
|
||||
results.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
||||
_sortBillingTransactionsBySysCreatedAtDesc(results, "getTransactions(accountId)")
|
||||
|
||||
return results[offset:offset + limit]
|
||||
except Exception as e:
|
||||
|
|
@ -674,7 +722,10 @@ class BillingObjects:
|
|||
transactions = self.getTransactions(account["id"], limit=limit)
|
||||
allTransactions.extend(transactions)
|
||||
|
||||
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
||||
_sortBillingTransactionsBySysCreatedAtDesc(
|
||||
allTransactions,
|
||||
"getTransactionsByMandate",
|
||||
)
|
||||
return allTransactions[:limit]
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -817,6 +868,108 @@ class BillingObjects:
|
|||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
|
||||
|
||||
def _parseSettingsDateTime(self, value: Any) -> Optional[datetime]:
|
||||
"""Parse datetime from billing settings row (ISO string or datetime)."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo:
|
||||
return value.astimezone(timezone.utc)
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
if isinstance(value, str):
|
||||
s = value.replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo:
|
||||
return dt.astimezone(timezone.utc)
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return None
|
||||
|
||||
def resetStorageBillingPeriod(self, mandateId: str, periodStartAt: datetime) -> None:
|
||||
"""Reset storage watermark state for a new subscription billing period (e.g. Stripe invoice.paid)."""
|
||||
if periodStartAt.tzinfo is None:
|
||||
periodStartAt = periodStartAt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
periodStartAt = periodStartAt.astimezone(timezone.utc)
|
||||
settings = self.getOrCreateSettings(mandateId)
|
||||
prev = self._parseSettingsDateTime(settings.get("storagePeriodStartAt"))
|
||||
if prev is not None and abs((prev - periodStartAt).total_seconds()) < 2:
|
||||
return
|
||||
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
|
||||
|
||||
usedMB = float(_getSubRoot().getMandateDataVolumeMB(mandateId))
|
||||
self.updateSettings(
|
||||
settings["id"],
|
||||
{
|
||||
"storageHighWatermarkMB": usedMB,
|
||||
"storageBilledUpToMB": 0.0,
|
||||
"storagePeriodStartAt": periodStartAt,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"Storage billing period reset for mandate %s at %s (usedMB=%.2f)",
|
||||
mandateId,
|
||||
periodStartAt.isoformat(),
|
||||
usedMB,
|
||||
)
|
||||
|
||||
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]:
|
||||
"""Debit prepay pool for new storage overage using period high-watermark (no credit on delete)."""
|
||||
settings = self.getSettings(mandateId)
|
||||
if not settings:
|
||||
return None
|
||||
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
|
||||
from modules.datamodels.datamodelSubscription import _getPlan
|
||||
|
||||
subIface = _getSubRoot()
|
||||
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
|
||||
sub = subIface.getOperativeForMandate(mandateId)
|
||||
plan = _getPlan(sub.get("planKey", "")) if sub else None
|
||||
includedMB = plan.maxDataVolumeMB if plan and plan.maxDataVolumeMB is not None else None
|
||||
if includedMB is None:
|
||||
return None
|
||||
|
||||
prevHigh = float(settings.get("storageHighWatermarkMB") or 0.0)
|
||||
high = max(prevHigh, usedMB)
|
||||
overageMB = max(0.0, high - float(includedMB))
|
||||
billed = float(settings.get("storageBilledUpToMB") or 0.0)
|
||||
deltaOverage = overageMB - billed
|
||||
settingsUpdates: Dict[str, Any] = {}
|
||||
if high != prevHigh:
|
||||
settingsUpdates["storageHighWatermarkMB"] = high
|
||||
if deltaOverage <= 1e-9:
|
||||
if settingsUpdates:
|
||||
self.updateSettings(settings["id"], settingsUpdates)
|
||||
return None
|
||||
|
||||
costCHF = round((deltaOverage / 1024.0) * float(STORAGE_PRICE_PER_GB_CHF), 4)
|
||||
if costCHF <= 0:
|
||||
if settingsUpdates:
|
||||
self.updateSettings(settings["id"], settingsUpdates)
|
||||
return None
|
||||
|
||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||
transaction = BillingTransaction(
|
||||
accountId=poolAccount["id"],
|
||||
transactionType=TransactionTypeEnum.DEBIT,
|
||||
amount=costCHF,
|
||||
description=f"Speicher-Überhang ({deltaOverage:.2f} MB über Plan)",
|
||||
referenceType=ReferenceTypeEnum.STORAGE,
|
||||
referenceId=mandateId,
|
||||
)
|
||||
created = self.createTransaction(transaction)
|
||||
settingsUpdates["storageBilledUpToMB"] = overageMB
|
||||
self.updateSettings(settings["id"], settingsUpdates)
|
||||
logger.info(
|
||||
"Storage overage billed mandate=%s deltaOverageMB=%.4f costCHF=%s",
|
||||
mandateId,
|
||||
deltaOverage,
|
||||
costCHF,
|
||||
)
|
||||
return created
|
||||
|
||||
# =========================================================================
|
||||
# Workflow Cost Query
|
||||
# =========================================================================
|
||||
|
|
@ -1027,7 +1180,7 @@ class BillingObjects:
|
|||
except Exception as e:
|
||||
logger.error(f"Error getting transactions for user: {e}")
|
||||
|
||||
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
||||
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getTransactionsForUser")
|
||||
return allTransactions[:limit]
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -1133,7 +1286,7 @@ class BillingObjects:
|
|||
logger.error(f"Error getting mandate transactions: {e}")
|
||||
|
||||
# Sort by creation date descending and limit
|
||||
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
||||
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getMandateTransactions")
|
||||
return allTransactions[:limit]
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -1320,5 +1473,5 @@ class BillingObjects:
|
|||
logger.error(f"Error getting user transactions for mandates: {e}")
|
||||
|
||||
# Sort by creation date descending and limit
|
||||
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
||||
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getUserTransactionsForMandates")
|
||||
return allTransactions[:limit]
|
||||
|
|
|
|||
|
|
@ -91,10 +91,20 @@ class KnowledgeObjects:
|
|||
|
||||
def deleteFileContentIndex(self, fileId: str) -> bool:
|
||||
"""Delete a FileContentIndex and all associated ContentChunks."""
|
||||
existing = self.getFileContentIndex(fileId)
|
||||
mandateId = (existing or {}).get("mandateId") or ""
|
||||
chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileId": fileId})
|
||||
for chunk in chunks:
|
||||
self.db.recordDelete(ContentChunk, chunk["id"])
|
||||
return self.db.recordDelete(FileContentIndex, fileId)
|
||||
ok = self.db.recordDelete(FileContentIndex, fileId)
|
||||
if ok and mandateId:
|
||||
try:
|
||||
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
||||
|
||||
_getRootInterface().reconcileMandateStorageBilling(str(mandateId))
|
||||
except Exception as ex:
|
||||
logger.warning("reconcileMandateStorageBilling after delete failed: %s", ex)
|
||||
return ok
|
||||
|
||||
# =========================================================================
|
||||
# ContentChunk CRUD
|
||||
|
|
|
|||
|
|
@ -297,13 +297,17 @@ class SubscriptionObjects:
|
|||
cap = plan.maxDataVolumeMB
|
||||
if cap is None:
|
||||
return True
|
||||
currentMB = self._getMandateDataVolumeMB(mandateId)
|
||||
currentMB = self.getMandateDataVolumeMB(mandateId)
|
||||
if currentMB + delta > cap:
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
||||
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
|
||||
|
||||
return True
|
||||
|
||||
def getMandateDataVolumeMB(self, mandateId: str) -> float:
|
||||
"""Total indexed data volume for the mandate (MB), for billing and capacity checks."""
|
||||
return self._getMandateDataVolumeMB(mandateId)
|
||||
|
||||
def _getMandateDataVolumeMB(self, mandateId: str) -> float:
|
||||
"""Sum RAG index size (FileContentIndex.totalSize) across all feature instances of the mandate."""
|
||||
try:
|
||||
|
|
@ -323,7 +327,7 @@ class SubscriptionObjects:
|
|||
plan = self.getPlan(sub.get("planKey", ""))
|
||||
if not plan or not plan.maxDataVolumeMB:
|
||||
return None
|
||||
usedMB = self._getMandateDataVolumeMB(mandateId)
|
||||
usedMB = self.getMandateDataVolumeMB(mandateId)
|
||||
limitMB = plan.maxDataVolumeMB
|
||||
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
||||
if percent >= 80:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Resp
|
|||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Import auth module
|
||||
|
|
@ -263,6 +263,9 @@ class BillingSettingsUpdate(BaseModel):
|
|||
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
|
||||
notifyOnWarning: Optional[bool] = None
|
||||
notifyEmails: Optional[List[str]] = None
|
||||
autoRechargeEnabled: Optional[bool] = None
|
||||
rechargeAmountCHF: Optional[float] = Field(None, gt=0)
|
||||
rechargeMaxPerMonth: Optional[int] = Field(None, ge=0)
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
|
|
@ -704,11 +707,13 @@ def createOrUpdateSettings(
|
|||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
settingsUpdate: BillingSettingsUpdate = Body(...),
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
_admin = Depends(requireSysAdminRole)
|
||||
):
|
||||
"""
|
||||
Create or update billing settings for a mandate (SysAdmin only).
|
||||
Create or update billing settings for a mandate.
|
||||
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
||||
"""
|
||||
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||
try:
|
||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||
existingSettings = billingInterface.getSettings(targetMandateId)
|
||||
|
|
@ -735,6 +740,21 @@ def createOrUpdateSettings(
|
|||
else True
|
||||
),
|
||||
notifyEmails=settingsUpdate.notifyEmails or [],
|
||||
autoRechargeEnabled=(
|
||||
settingsUpdate.autoRechargeEnabled
|
||||
if settingsUpdate.autoRechargeEnabled is not None
|
||||
else False
|
||||
),
|
||||
rechargeAmountCHF=(
|
||||
settingsUpdate.rechargeAmountCHF
|
||||
if settingsUpdate.rechargeAmountCHF is not None
|
||||
else 10.0
|
||||
),
|
||||
rechargeMaxPerMonth=(
|
||||
settingsUpdate.rechargeMaxPerMonth
|
||||
if settingsUpdate.rechargeMaxPerMonth is not None
|
||||
else 3
|
||||
),
|
||||
)
|
||||
|
||||
return billingInterface.createSettings(newSettings)
|
||||
|
|
@ -1103,7 +1123,8 @@ def _handleSubscriptionWebhook(event) -> None:
|
|||
from datetime import datetime, timezone
|
||||
|
||||
obj = event.data.object
|
||||
stripeSubId = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription")
|
||||
rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription")
|
||||
stripeSubId = rawSub.get("id") if isinstance(rawSub, dict) else rawSub
|
||||
if not stripeSubId:
|
||||
logger.warning("Subscription webhook %s has no subscription ID", event.type)
|
||||
return
|
||||
|
|
@ -1209,6 +1230,15 @@ def _handleSubscriptionWebhook(event) -> None:
|
|||
logger.error("Failed to notify about trial ending: %s", e)
|
||||
|
||||
elif event.type == "invoice.paid":
|
||||
period_ts = obj.get("period_start")
|
||||
if period_ts:
|
||||
period_start_at = datetime.fromtimestamp(int(period_ts), tz=timezone.utc)
|
||||
try:
|
||||
billing_if = _getRootInterface()
|
||||
billing_if.resetStorageBillingPeriod(mandateId, period_start_at)
|
||||
billing_if.reconcileMandateStorageBilling(mandateId)
|
||||
except Exception as ex:
|
||||
logger.error("Storage billing on invoice.paid failed: %s", ex)
|
||||
logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId)
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
Routes for local security and authentication.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body, Query, Path
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body, Path
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
|
@ -822,125 +822,6 @@ def password_reset(
|
|||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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)."""
|
||||
rootInterface = getRootInterface()
|
||||
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||
|
||||
mandateId = request.headers.get("X-Mandate-Id") or None
|
||||
userId = str(currentUser.id)
|
||||
|
||||
prefs = rootInterface.db.getRecordset(
|
||||
UserVoicePreferences,
|
||||
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||
)
|
||||
if prefs:
|
||||
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||
return UserVoicePreferences(userId=userId, mandateId=mandateId).model_dump()
|
||||
|
||||
|
||||
@router.put("/voice-preferences")
|
||||
@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 (upsert)."""
|
||||
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)
|
||||
updated = rootInterface.db.getRecordset(UserVoicePreferences, recordFilter={"id": existingId})
|
||||
return updated[0] if updated else {"message": "Updated", **updateData}
|
||||
else:
|
||||
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
|
||||
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
||||
return created if isinstance(created, dict) else created.model_dump()
|
||||
|
||||
|
||||
@router.get("/voice/languages")
|
||||
@limiter.limit("120/minute")
|
||||
async def getVoiceLanguages(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Return available TTS languages (user-level, no instance context needed)."""
|
||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||
voiceInterface = getVoiceInterface(currentUser)
|
||||
languagesResult = await voiceInterface.getAvailableLanguages()
|
||||
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
|
||||
return {"languages": languageList}
|
||||
|
||||
|
||||
@router.get("/voice/voices")
|
||||
@limiter.limit("120/minute")
|
||||
async def getVoiceVoices(
|
||||
request: Request,
|
||||
language: str = Query("de-DE"),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Return available TTS voices for a given language."""
|
||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||
voiceInterface = getVoiceInterface(currentUser)
|
||||
voicesResult = await voiceInterface.getAvailableVoices(language)
|
||||
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
|
||||
return {"voices": voiceList}
|
||||
|
||||
|
||||
@router.post("/voice/test")
|
||||
@limiter.limit("30/minute")
|
||||
async def testVoice(
|
||||
request: Request,
|
||||
body: Dict[str, Any] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Test a specific voice with a sample text."""
|
||||
import base64
|
||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||
|
||||
text = body.get("text", "Hallo, das ist ein Stimmtest.")
|
||||
language = body.get("language", "de-DE")
|
||||
voiceId = body.get("voiceId")
|
||||
|
||||
voiceInterface = getVoiceInterface(currentUser)
|
||||
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
|
||||
if result and isinstance(result, dict):
|
||||
audioContent = result.get("audioContent")
|
||||
if audioContent:
|
||||
audioB64 = base64.b64encode(
|
||||
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
|
||||
).decode()
|
||||
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
|
||||
return {"success": False, "error": "TTS returned no audio"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Neutralization Mappings (user-level, view/delete)
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -190,12 +190,22 @@ def getSubscriptionInfo(
|
|||
mandateId = adminMandateIds[0]
|
||||
|
||||
if not mandateId:
|
||||
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
|
||||
return {
|
||||
"plan": None,
|
||||
"maxDataVolumeMB": None,
|
||||
"maxFeatureInstances": None,
|
||||
"budgetAiCHF": 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}
|
||||
return {
|
||||
"plan": None,
|
||||
"maxDataVolumeMB": None,
|
||||
"maxFeatureInstances": None,
|
||||
"budgetAiCHF": None,
|
||||
}
|
||||
|
||||
sub = subs[0]
|
||||
plan = BUILTIN_PLANS.get(sub.get("planKey"))
|
||||
|
|
@ -206,12 +216,18 @@ def getSubscriptionInfo(
|
|||
"status": sub.get("status"),
|
||||
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
|
||||
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
|
||||
"budgetAiCHF": plan.budgetAiCHF 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}
|
||||
return {
|
||||
"plan": None,
|
||||
"maxDataVolumeMB": None,
|
||||
"maxFeatureInstances": None,
|
||||
"budgetAiCHF": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/features", response_model=List[StoreFeatureResponse])
|
||||
|
|
|
|||
327
modules/routes/routeVoiceUser.py
Normal file
327
modules/routes/routeVoiceUser.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
User-scoped voice settings and TTS/STT catalog endpoints.
|
||||
|
||||
Uses modules.interfaces.interfaceVoiceObjects (voice core) and persists preferences
|
||||
via UserVoicePreferences — same domain as routeVoiceGoogle (Google connector ops).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
|
||||
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.datamodels.datamodelUam import User, UserVoicePreferences
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/voice",
|
||||
tags=["Voice User"],
|
||||
responses={
|
||||
404: {"description": "Not found"},
|
||||
400: {"description": "Bad request"},
|
||||
401: {"description": "Unauthorized"},
|
||||
403: {"description": "Forbidden"},
|
||||
500: {"description": "Internal server error"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/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)."""
|
||||
rootInterface = getRootInterface()
|
||||
mandateId = request.headers.get("X-Mandate-Id") or None
|
||||
userId = str(currentUser.id)
|
||||
|
||||
prefs = rootInterface.db.getRecordset(
|
||||
UserVoicePreferences,
|
||||
recordFilter={"userId": userId, "mandateId": mandateId},
|
||||
)
|
||||
if prefs:
|
||||
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||
return UserVoicePreferences(userId=userId, mandateId=mandateId).model_dump()
|
||||
|
||||
|
||||
@router.put("/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 (upsert)."""
|
||||
rootInterface = getRootInterface()
|
||||
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)
|
||||
updated = rootInterface.db.getRecordset(UserVoicePreferences, recordFilter={"id": existingId})
|
||||
return updated[0] if updated else {"message": "Updated", **updateData}
|
||||
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
|
||||
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
||||
return created if isinstance(created, dict) else created.model_dump()
|
||||
|
||||
|
||||
@router.get("/languages")
|
||||
@limiter.limit("120/minute")
|
||||
async def getVoiceLanguages(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Return available TTS languages (user-level, no instance context needed)."""
|
||||
voiceInterface = getVoiceInterface(currentUser)
|
||||
languagesResult = await voiceInterface.getAvailableLanguages()
|
||||
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
|
||||
return {"languages": languageList}
|
||||
|
||||
|
||||
@router.get("/voices")
|
||||
@limiter.limit("120/minute")
|
||||
async def getVoiceVoices(
|
||||
request: Request,
|
||||
language: str = Query("de-DE"),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Return available TTS voices for a given language."""
|
||||
voiceInterface = getVoiceInterface(currentUser)
|
||||
voicesResult = await voiceInterface.getAvailableVoices(language)
|
||||
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
|
||||
return {"voices": voiceList}
|
||||
|
||||
|
||||
# Same minimum as modules.serviceCenter.services.serviceAi.mainServiceAi._checkBillingBeforeAiCall
|
||||
_MIN_AI_BILLING_ESTIMATE_CHF = 0.01
|
||||
|
||||
|
||||
def _userMandateIds(rootInterface, currentUser: User):
|
||||
memberships = rootInterface.getUserMandates(str(currentUser.id))
|
||||
out = []
|
||||
for um in memberships:
|
||||
mid = getattr(um, "mandateId", None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||
if mid:
|
||||
out.append(str(mid))
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def _mandatePassesAiPoolBilling(currentUser: User, mandateId: str, userId: str) -> bool:
|
||||
"""True if mandate pool passes the same billing gate as AI calls (subscription + pool >= estimate)."""
|
||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||
|
||||
bi = getBillingInterface(currentUser, mandateId)
|
||||
res = bi.checkBalance(mandateId, userId, _MIN_AI_BILLING_ESTIMATE_CHF)
|
||||
return bool(res.allowed)
|
||||
|
||||
|
||||
def _mandatePoolBalanceChf(currentUser: User, mandateId: str) -> float:
|
||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||
|
||||
bi = getBillingInterface(currentUser, mandateId)
|
||||
acc = bi.getMandateAccount(mandateId)
|
||||
if not acc:
|
||||
return 0.0
|
||||
return float(acc.get("balance", 0.0) or 0.0)
|
||||
|
||||
|
||||
def _resolveMandateIdForVoiceTestAi(request: Request, currentUser: User) -> str:
|
||||
"""
|
||||
AI sample billing uses mandate pool (PREPAY), not per-user wallet.
|
||||
Prefer X-Mandate-Id when the user is a member and that mandate's pool can pay;
|
||||
otherwise pick the member mandate with the highest pool balance that passes the AI billing check.
|
||||
"""
|
||||
rootInterface = getRootInterface()
|
||||
userId = str(currentUser.id)
|
||||
memberIds = _userMandateIds(rootInterface, currentUser)
|
||||
if not memberIds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
"Voice test needs at least one mandate membership for AI billing. "
|
||||
"Join a mandate or open the app from a mandate context."
|
||||
),
|
||||
)
|
||||
|
||||
headerRaw = (request.headers.get("X-Mandate-Id") or request.headers.get("x-mandate-id") or "").strip()
|
||||
if headerRaw:
|
||||
if headerRaw not in memberIds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="X-Mandate-Id is not a mandate you belong to.",
|
||||
)
|
||||
if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
|
||||
logger.info(
|
||||
"Voice test AI billing: using header mandate %s (pool ok for estimate %.4f CHF)",
|
||||
headerRaw,
|
||||
_MIN_AI_BILLING_ESTIMATE_CHF,
|
||||
)
|
||||
return headerRaw
|
||||
logger.warning(
|
||||
"Voice test AI billing: header mandate %s has insufficient mandate pool or subscription; "
|
||||
"trying other memberships",
|
||||
headerRaw,
|
||||
)
|
||||
|
||||
bestMid = None
|
||||
bestBal = -1.0
|
||||
for mid in memberIds:
|
||||
if not _mandatePassesAiPoolBilling(currentUser, mid, userId):
|
||||
continue
|
||||
bal = _mandatePoolBalanceChf(currentUser, mid)
|
||||
if bal > bestBal:
|
||||
bestBal = bal
|
||||
bestMid = mid
|
||||
|
||||
if bestMid:
|
||||
logger.info(
|
||||
"Voice test AI billing: selected mandate %s (mandate pool %.2f CHF, estimate %.4f CHF)",
|
||||
bestMid,
|
||||
bestBal,
|
||||
_MIN_AI_BILLING_ESTIMATE_CHF,
|
||||
)
|
||||
return bestMid
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=(
|
||||
"No mandate you belong to has sufficient shared pool balance for AI (or subscription inactive). "
|
||||
"Top up the mandate pool or use a mandate with budget."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _sanitizeAiTtsSample(raw: str) -> str:
|
||||
s = (raw or "").strip()
|
||||
if s.startswith("```"):
|
||||
nl = s.find("\n")
|
||||
if nl != -1:
|
||||
s = s[nl + 1 :]
|
||||
if s.rstrip().endswith("```"):
|
||||
s = s.rstrip()[:-3].strip()
|
||||
if len(s) >= 2 and ((s[0] == s[-1] == '"') or (s[0] == s[-1] == "'")):
|
||||
s = s[1:-1].strip()
|
||||
return s
|
||||
|
||||
|
||||
async def _generateTtsSampleTextForLocale(
|
||||
request: Request,
|
||||
currentUser: User,
|
||||
localeTag: str,
|
||||
) -> str:
|
||||
from modules.serviceCenter import getService
|
||||
from modules.serviceCenter.context import ServiceCenterContext
|
||||
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
|
||||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||
BillingContextError,
|
||||
InsufficientBalanceException,
|
||||
ProviderNotAllowedException,
|
||||
)
|
||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException
|
||||
|
||||
mandateId = _resolveMandateIdForVoiceTestAi(request, currentUser)
|
||||
ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=None)
|
||||
aiService = getService("ai", ctx)
|
||||
|
||||
systemPrompt = (
|
||||
"You write short text-to-speech demo lines for end users.\n"
|
||||
"Task: Output exactly one or two natural sentences a user would enjoy hearing when testing a voice.\n"
|
||||
"The entire output MUST be written ONLY in the natural spoken language that matches the given "
|
||||
"BCP-47 locale tag. Do not use any other language.\n"
|
||||
"Do not mention locales, tags, tests, artificial intelligence, or these instructions.\n"
|
||||
"No quotation marks around the text. No markdown. Plain text only."
|
||||
)
|
||||
userPrompt = f"BCP-47 locale tag: `{localeTag}`.\nWrite the sample now."
|
||||
|
||||
aiRequest = AiCallRequest(
|
||||
prompt=userPrompt,
|
||||
context=systemPrompt,
|
||||
requireNeutralization=False,
|
||||
options=AiCallOptions(
|
||||
operationType=OperationTypeEnum.DATA_GENERATE,
|
||||
priority=PriorityEnum.SPEED,
|
||||
processingMode=ProcessingModeEnum.BASIC,
|
||||
compressPrompt=False,
|
||||
compressContext=False,
|
||||
temperature=0.75,
|
||||
maxParts=1,
|
||||
),
|
||||
)
|
||||
try:
|
||||
response = await aiService.callAi(aiRequest)
|
||||
except SubscriptionInactiveException as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message) from e
|
||||
except InsufficientBalanceException as e:
|
||||
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=str(e)) from e
|
||||
except ProviderNotAllowedException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=getattr(e, "message", None) or str(e),
|
||||
) from e
|
||||
except BillingContextError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
content = _sanitizeAiTtsSample(getattr(response, "content", None) or "")
|
||||
if getattr(response, "errorCount", 0) or not content:
|
||||
logger.warning("Voice test AI sample empty or errorCount=%s", getattr(response, "errorCount", None))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Could not generate voice test sample text.",
|
||||
)
|
||||
if len(content) > 500:
|
||||
content = content[:500].rstrip()
|
||||
return content
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
@limiter.limit("30/minute")
|
||||
async def testVoice(
|
||||
request: Request,
|
||||
body: Dict[str, Any] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Test a specific voice. Sample text is AI-generated in the voice locale unless `text` is supplied."""
|
||||
textRaw = body.get("text")
|
||||
language = body.get("language", "de-DE")
|
||||
voiceId = body.get("voiceId")
|
||||
|
||||
text = (textRaw or "").strip() if isinstance(textRaw, str) else ""
|
||||
if not text:
|
||||
text = await _generateTtsSampleTextForLocale(request, currentUser, language)
|
||||
|
||||
voiceInterface = getVoiceInterface(currentUser)
|
||||
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
|
||||
if result and isinstance(result, dict):
|
||||
audioContent = result.get("audioContent")
|
||||
if audioContent:
|
||||
audioB64 = base64.b64encode(
|
||||
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
|
||||
).decode()
|
||||
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
|
||||
return {"success": False, "error": "TTS returned no audio"}
|
||||
|
|
@ -21,6 +21,8 @@ class ServiceCenterContext:
|
|||
workflow_id: Optional[str] = None
|
||||
workflow: Any = None
|
||||
requireNeutralization: Optional[bool] = None
|
||||
# When workflow is absent (e.g. workspace agent), billing/UI still need feature code for transactions.
|
||||
feature_code: Optional[str] = None
|
||||
|
||||
@property
|
||||
def mandateId(self) -> Optional[str]:
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ class AgentService:
|
|||
|
||||
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
|
||||
"""Create the AI call function that wraps serviceAi with billing."""
|
||||
ctxNeutralization = getattr(self.ctx, 'requireNeutralization', None)
|
||||
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
|
||||
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
|
||||
if ctxNeutralization is not None and request.requireNeutralization is None:
|
||||
request.requireNeutralization = ctxNeutralization
|
||||
|
|
@ -332,7 +332,7 @@ class AgentService:
|
|||
|
||||
def _createAiCallStreamFn(self):
|
||||
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
|
||||
ctxNeutralization = getattr(self.ctx, 'requireNeutralization', None)
|
||||
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
|
||||
async def _aiCallStreamFn(request: AiCallRequest):
|
||||
if ctxNeutralization is not None and request.requireNeutralization is None:
|
||||
request.requireNeutralization = ctxNeutralization
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ class _ServicesAdapter:
|
|||
|
||||
@property
|
||||
def featureCode(self) -> Optional[str]:
|
||||
fc = getattr(self._context, "feature_code", None)
|
||||
if fc and str(fc).strip():
|
||||
return str(fc).strip()
|
||||
w = self.workflow
|
||||
if w and hasattr(w, "feature") and w.feature:
|
||||
return getattr(w.feature, "code", None)
|
||||
|
|
@ -742,9 +745,8 @@ detectedIntent-Werte:
|
|||
|
||||
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
|
||||
logger.warning(
|
||||
f"Billing check failed for user {user.id}: "
|
||||
f"Balance {balance_str} CHF, "
|
||||
f"Reason: {reason}"
|
||||
f"AI billing check failed (mandate pool): mandate={mandateId} user={user.id} "
|
||||
f"poolBalance={balance_str} CHF required~={estimatedCost:.4f} CHF reason={reason}"
|
||||
)
|
||||
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
|
||||
maybeEmailMandatePoolExhausted(
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ def getService(currentUser: User, mandateId: str, featureInstanceId: str = None,
|
|||
|
||||
def _get_feature_code_from_context(context) -> Optional[str]:
|
||||
"""Extract featureCode from ServiceCenterContext."""
|
||||
explicit = getattr(context, "feature_code", None)
|
||||
if explicit and str(explicit).strip():
|
||||
return str(explicit).strip()
|
||||
if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature:
|
||||
return getattr(context.workflow.feature, "code", None)
|
||||
return getattr(context.workflow, "featureCode", None) if context.workflow else None
|
||||
|
|
|
|||
|
|
@ -210,6 +210,13 @@ class KnowledgeService:
|
|||
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")
|
||||
if mandateId:
|
||||
try:
|
||||
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
||||
|
||||
_getRootInterface().reconcileMandateStorageBilling(str(mandateId))
|
||||
except Exception as ex:
|
||||
logger.warning("reconcileMandateStorageBilling after index failed: %s", ex)
|
||||
return index
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -99,25 +99,44 @@ try:
|
|||
except Exception as e:
|
||||
logger.warning(f"Could not refresh APP_CONFIG: {e}")
|
||||
|
||||
# Alle PowerOn Datenbanken
|
||||
# Alle PowerOn Datenbanken (keep in sync with interfaceBootstrap._POWERON_DATABASE_NAMES)
|
||||
ALL_DATABASES = [
|
||||
"poweron_app", # Haupt-App: User, Mandate, RBAC, Features
|
||||
"poweron_chat", # Chat-Konversationen
|
||||
"poweron_chatbot", # Chatbot-Feature: Konversationen, Nachrichten, Logs
|
||||
"poweron_management", # Workflows, Prompts, Connections
|
||||
"poweron_realestate", # Real Estate
|
||||
"poweron_trustee", # Trustee
|
||||
"poweron_app",
|
||||
"poweron_automation",
|
||||
"poweron_automation2",
|
||||
"poweron_billing",
|
||||
"poweron_chat",
|
||||
"poweron_chatbot",
|
||||
"poweron_commcoach",
|
||||
"poweron_knowledge",
|
||||
"poweron_management",
|
||||
"poweron_neutralization",
|
||||
"poweron_realestate",
|
||||
"poweron_teamsbot",
|
||||
"poweron_test",
|
||||
"poweron_trustee",
|
||||
"poweron_workspace",
|
||||
]
|
||||
|
||||
# Datenbank-Konfiguration: Mapping von DB-Name zu Config-Prefix
|
||||
# Jede Datenbank hat ihre eigenen Variablen: DB_APP_HOST, DB_CHAT_HOST, etc.
|
||||
# Unlisted names use prefix "DB" (DB_HOST, DB_USER, …) via _getDbConfig fallback.
|
||||
DATABASE_CONFIG = {
|
||||
"poweron_app": "DB_APP", # DB_APP_HOST, DB_APP_USER, DB_APP_PASSWORD_SECRET, etc.
|
||||
"poweron_chat": "DB_CHAT", # DB_CHAT_HOST, DB_CHAT_USER, etc.
|
||||
"poweron_chatbot": "DB_CHATBOT", # DB_CHATBOT_* (fallsback to DB_*)
|
||||
"poweron_app": "DB_APP",
|
||||
"poweron_chat": "DB_CHAT",
|
||||
"poweron_chatbot": "DB_CHATBOT",
|
||||
"poweron_management": "DB_MANAGEMENT",
|
||||
"poweron_realestate": "DB_REALESTATE",
|
||||
"poweron_trustee": "DB_TRUSTEE",
|
||||
"poweron_automation": "DB",
|
||||
"poweron_automation2": "DB",
|
||||
"poweron_billing": "DB",
|
||||
"poweron_commcoach": "DB",
|
||||
"poweron_knowledge": "DB",
|
||||
"poweron_neutralization": "DB",
|
||||
"poweron_teamsbot": "DB",
|
||||
"poweron_test": "DB",
|
||||
"poweron_workspace": "DB",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -284,10 +284,13 @@ except Exception as 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:
|
||||
voiceUserPath = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"modules", "routes", "routeVoiceUser.py",
|
||||
)
|
||||
with open(voiceUserPath, "r") as f:
|
||||
source = f.read()
|
||||
_check("Voice preferences GET endpoint", "voice-preferences" in source and "getVoicePreferences" in source)
|
||||
_check("Voice preferences GET endpoint", '"/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}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue