Merge pull request #111 from valueonag/int

Int
This commit is contained in:
Patrick Motsch 2026-03-22 01:27:55 +01:00 committed by GitHub
commit d40afae0a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3103 additions and 1589 deletions

16
app.py
View file

@ -8,7 +8,8 @@ from urllib.parse import quote_plus
os.environ["NUMEXPR_MAX_THREADS"] = "12" os.environ["NUMEXPR_MAX_THREADS"] = "12"
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -493,6 +494,19 @@ from slowapi import _rate_limit_exceeded_handler
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
async def _insufficientBalanceHandler(request: Request, exc: Exception):
"""HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE)."""
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
return JSONResponse(status_code=402, content={"detail": payload})
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
app.add_exception_handler(InsufficientBalanceException, _insufficientBalanceHandler)
# CSRF protection middleware # CSRF protection middleware
from modules.auth import CSRFMiddleware from modules.auth import CSRFMiddleware
from modules.auth import ( from modules.auth import (

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5 APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects # OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09 STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFRE
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY= Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE= Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_TENANT_ID = common Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
# Google Cloud Speech Services configuration # Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0= Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5 APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects # OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/callback Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/callback Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = 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 STRIPE_SECRET_KEY_SECRET = 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
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg= Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI= Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_TENANT_ID = common Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
# Google Cloud Speech Services configuration # Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0= Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5 APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects # OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/callback Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/callback Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = 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 STRIPE_SECRET_KEY_SECRET = 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
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZ
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo= Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA= Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_TENANT_ID = common Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
# Google Cloud Speech Services configuration # Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0= Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=

View file

@ -23,7 +23,7 @@ from modules.shared.configuration import APP_CONFIG
from modules.security.rootAccess import getRootDbAppConnector, getRootUser from modules.security.rootAccess import getRootDbAppConnector, getRootUser
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.datamodels.datamodelRbac import AccessRule from modules.datamodels.datamodelRbac import AccessRule
# Get Config Data # Get Config Data
@ -189,10 +189,46 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}" f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
) )
raise credentialsException raise credentialsException
elif token_authority == str(AuthAuthority.GOOGLE.value):
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.GOOGLE,
sessionId=sessionId,
mandateId=None,
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
)
if not active_token:
logger.info(
f"Google JWT db record not active/valid: jti={tokenId}, userId={user.id}"
)
raise credentialsException
elif token_authority == str(AuthAuthority.MSFT.value):
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.MSFT,
sessionId=sessionId,
mandateId=None,
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
)
if not active_token:
logger.info(
f"Microsoft JWT db record not active/valid: jti={tokenId}, userId={user.id}"
)
raise credentialsException
else: else:
# No DB record for this token. If the claim says local (or missing/unknown), require DB record. # No DB record for this token. If the claim says local (or missing/unknown), require DB record.
if normalized_authority in (None, "", str(AuthAuthority.LOCAL.value)): if normalized_authority in (
logger.info("Local JWT without server record or missing authority claim") None,
"",
str(AuthAuthority.LOCAL.value),
str(AuthAuthority.GOOGLE.value),
str(AuthAuthority.MSFT.value),
):
logger.info(
"JWT without server record or missing authority claim (local/google/msft require DB row)"
)
raise credentialsException raise credentialsException
except HTTPException: except HTTPException:
raise raise

View file

@ -23,11 +23,18 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# Paths that are exempt from CSRF protection # Paths that are exempt from CSRF protection
self.exempt_paths = exempt_paths or { self.exempt_paths = exempt_paths or {
"/api/local/login", "/api/local/login",
"/api/local/register", "/api/local/register",
"/api/msft/login", # OAuth Auth app + Data app (GET redirects / callbacks)
"/api/google/login", "/api/msft/auth/login",
"/api/msft/callback", "/api/msft/auth/login/callback",
"/api/google/callback", "/api/msft/auth/connect",
"/api/msft/auth/connect/callback",
"/api/msft/adminconsent",
"/api/msft/adminconsent/callback",
"/api/google/auth/login",
"/api/google/auth/login/callback",
"/api/google/auth/connect",
"/api/google/auth/connect/callback",
"/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature) "/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature)
} }

View file

@ -0,0 +1,42 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""
# Google — Auth app only (no Gmail/Drive API scopes)
googleAuthScopes = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
]
# Google — Data app (Gmail + Drive + identity for token responses)
googleDataScopes = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/drive.readonly",
]
# Microsoft — Auth app: Graph profile only (MSAL adds openid, profile, offline_access, …)
msftAuthScopes = [
"User.Read",
]
# Microsoft — Data app (delegated; requires admin consent for several)
msftDataScopes = [
"User.Read",
"Mail.ReadWrite",
"Mail.Send",
"Files.ReadWrite.All",
"Sites.ReadWrite.All",
"Team.ReadBasic.All",
"OnlineMeetings.Read",
"Chat.ReadWrite",
"ChatMessage.Send",
]
def msftDataScopesForRefresh() -> str:
"""Space-separated scope string identical to authorization request (Token v2 refresh)."""
return " ".join(msftDataScopes)

View file

@ -9,10 +9,11 @@ import logging
import httpx import httpx
from typing import Optional, Dict, Any, Callable from typing import Optional, Dict, Any, Callable
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.datamodels.datamodelUam import AuthAuthority from modules.datamodels.datamodelUam import AuthAuthority
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp
from modules.auth.oauthProviderConfig import msftDataScopesForRefresh
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,14 +21,14 @@ class TokenManager:
"""Centralized token management service""" """Centralized token management service"""
def __init__(self): def __init__(self):
# Microsoft OAuth configuration # Microsoft Data-app OAuth (refresh + token exchange for connections)
self.msft_client_id = APP_CONFIG.get("Service_MSFT_CLIENT_ID") self.msft_client_id = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_ID")
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET") self.msft_client_secret = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_SECRET")
self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common") self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
# Google OAuth configuration # Google Data-app OAuth
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID") self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID")
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET") self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET")
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]: def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
"""Refresh Microsoft OAuth token using refresh token""" """Refresh Microsoft OAuth token using refresh token"""
@ -49,7 +50,7 @@ class TokenManager:
"client_secret": self.msft_client_secret, "client_secret": self.msft_client_secret,
"grant_type": "refresh_token", "grant_type": "refresh_token",
"refresh_token": refreshToken, "refresh_token": refreshToken,
"scope": "Mail.ReadWrite Mail.Send Mail.ReadWrite.Shared User.Read" "scope": msftDataScopesForRefresh(),
} }
logger.debug(f"refreshMicrosoftToken: Refresh request data prepared (refreshToken length: {len(refreshToken) if refreshToken else 0})") logger.debug(f"refreshMicrosoftToken: Refresh request data prepared (refreshToken length: {len(refreshToken) if refreshToken else 0})")
@ -68,6 +69,7 @@ class TokenManager:
userId=userId, userId=userId,
authority=AuthAuthority.MSFT, authority=AuthAuthority.MSFT,
connectionId=oldToken.connectionId, # Preserve connection ID connectionId=oldToken.connectionId, # Preserve connection ID
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=tokenData["access_token"], tokenAccess=tokenData["access_token"],
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided
tokenType=tokenData.get("token_type", "bearer"), tokenType=tokenData.get("token_type", "bearer"),
@ -128,6 +130,7 @@ class TokenManager:
userId=userId, userId=userId,
authority=AuthAuthority.GOOGLE, authority=AuthAuthority.GOOGLE,
connectionId=oldToken.connectionId, # Preserve connection ID connectionId=oldToken.connectionId, # Preserve connection ID
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=tokenData["access_token"], tokenAccess=tokenData["access_token"],
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided
tokenType=tokenData.get("token_type", "bearer"), tokenType=tokenData.get("token_type", "bearer"),
@ -164,6 +167,15 @@ class TokenManager:
try: try:
logger.debug(f"refreshToken: Starting refresh for token {oldToken.id}, authority: {oldToken.authority}") logger.debug(f"refreshToken: Starting refresh for token {oldToken.id}, authority: {oldToken.authority}")
logger.debug(f"refreshToken: Token details: userId={oldToken.userId}, connectionId={oldToken.connectionId}, hasRefreshToken={bool(oldToken.tokenRefresh)}") logger.debug(f"refreshToken: Token details: userId={oldToken.userId}, connectionId={oldToken.connectionId}, hasRefreshToken={bool(oldToken.tokenRefresh)}")
_tp = (
oldToken.tokenPurpose.value
if isinstance(oldToken.tokenPurpose, TokenPurpose)
else oldToken.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning("refreshToken: skipped — token is not dataConnection")
return None
# Cooldown: avoid refreshing too frequently if a workflow triggers refresh repeatedly # Cooldown: avoid refreshing too frequently if a workflow triggers refresh repeatedly
# Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed # Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed
@ -266,6 +278,16 @@ class TokenManager:
token = interface.getConnectionToken(connectionId) token = interface.getConnectionToken(connectionId)
if not token: if not token:
return None return None
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning(
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
)
return None
return self.ensureFreshToken( return self.ensureFreshToken(
token, token,
secondsBeforeExpiry=secondsBeforeExpiry, secondsBeforeExpiry=secondsBeforeExpiry,

View file

@ -11,11 +11,13 @@ import uuid
class BillingModelEnum(str, Enum): class BillingModelEnum(str, Enum):
"""Billing model types.""" """Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE)."""
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
CREDIT_POSTPAY = "CREDIT_POSTPAY" # Credit with monthly invoice (requires billing address)
UNLIMITED = "UNLIMITED" # No cost limitation (internal mandates only)
# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
DEFAULT_USER_CREDIT_CHF = 5.0
class AccountTypeEnum(str, Enum): class AccountTypeEnum(str, Enum):
@ -46,30 +48,6 @@ class PeriodTypeEnum(str, Enum):
YEAR = "YEAR" YEAR = "YEAR"
class BillingAddress(BaseModel):
"""Billing address for CREDIT_POSTPAY mandates."""
company: str = Field(..., description="Company name")
street: str = Field(..., description="Street and number")
zip: str = Field(..., description="Postal code")
city: str = Field(..., description="City")
country: str = Field(default="CH", description="Country code")
vatNumber: Optional[str] = Field(None, description="VAT number (optional)")
registerModelLabels(
"BillingAddress",
{"en": "Billing Address", "de": "Rechnungsadresse"},
{
"company": {"en": "Company", "de": "Firma"},
"street": {"en": "Street", "de": "Strasse"},
"zip": {"en": "ZIP", "de": "PLZ"},
"city": {"en": "City", "de": "Ort"},
"country": {"en": "Country", "de": "Land"},
"vatNumber": {"en": "VAT Number", "de": "MwSt-Nummer"},
},
)
class BillingAccount(BaseModel): class BillingAccount(BaseModel):
"""Billing account for mandate or user-mandate combination.""" """Billing account for mandate or user-mandate combination."""
id: str = Field( id: str = Field(
@ -79,7 +57,6 @@ class BillingAccount(BaseModel):
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)") userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER") accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
balance: float = Field(default=0.0, description="Current balance in CHF") balance: float = Field(default=0.0, description="Current balance in CHF")
creditLimit: Optional[float] = Field(None, description="Credit limit in CHF (only for CREDIT_POSTPAY)")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF") warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp") lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
enabled: bool = Field(default=True, description="Account is active") enabled: bool = Field(default=True, description="Account is active")
@ -94,7 +71,6 @@ registerModelLabels(
"userId": {"en": "User ID", "de": "Benutzer-ID"}, "userId": {"en": "User ID", "de": "Benutzer-ID"},
"accountType": {"en": "Account Type", "de": "Kontotyp"}, "accountType": {"en": "Account Type", "de": "Kontotyp"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"}, "balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"creditLimit": {"en": "Credit Limit (CHF)", "de": "Kreditlimit (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"}, "warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"}, "lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
"enabled": {"en": "Enabled", "de": "Aktiv"}, "enabled": {"en": "Enabled", "de": "Aktiv"},
@ -161,15 +137,17 @@ class BillingSettings(BaseModel):
billingModel: BillingModelEnum = Field(..., description="Billing model") billingModel: BillingModelEnum = Field(..., description="Billing model")
# Configuration # Configuration
defaultUserCredit: float = Field(default=10.0, description="Initial credit in CHF for new users (PREPAY_USER)") defaultUserCredit: float = Field(
default=0.0,
description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.",
)
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage") warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
blockOnZeroBalance: bool = Field(default=True, description="Block AI features when balance is zero")
# Billing address (required for CREDIT_POSTPAY) # Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
billingAddress: Optional[BillingAddress] = Field(None, description="Billing address") notifyEmails: List[str] = Field(
default_factory=list,
# Notifications description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
notifyEmails: List[str] = Field(default_factory=list, description="Email addresses for billing notifications") )
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached") notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
@ -180,11 +158,15 @@ registerModelLabels(
"id": {"en": "ID", "de": "ID"}, "id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"}, "billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
"defaultUserCredit": {"en": "Default User Credit (CHF)", "de": "Standard-Startguthaben (CHF)"}, "defaultUserCredit": {
"en": "Root start credit (CHF)",
"de": "Startguthaben nur Root-Mandant (CHF)",
},
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"}, "warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"blockOnZeroBalance": {"en": "Block on Zero Balance", "de": "Bei 0 blockieren"}, "notifyEmails": {
"billingAddress": {"en": "Billing Address", "de": "Rechnungsadresse"}, "en": "Billing notification emails (owner / admin)",
"notifyEmails": {"en": "Notification Emails", "de": "Benachrichtigungs-Emails"}, "de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
},
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"}, "notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
}, },
) )
@ -257,7 +239,6 @@ class BillingBalanceResponse(BaseModel):
currency: str = "CHF" currency: str = "CHF"
warningThreshold: float warningThreshold: float
isWarning: bool isWarning: bool
creditLimit: Optional[float] = None
class BillingStatisticsChartData(BaseModel): class BillingStatisticsChartData(BaseModel):
@ -285,3 +266,16 @@ class BillingCheckResult(BaseModel):
currentBalance: Optional[float] = None currentBalance: Optional[float] = None
requiredAmount: Optional[float] = None requiredAmount: Optional[float] = None
billingModel: Optional[BillingModelEnum] = None billingModel: Optional[BillingModelEnum] = None
def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum:
"""Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE."""
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return BillingModelEnum.PREPAY_MANDATE
s = str(raw).strip().upper()
if s == "UNLIMITED":
return BillingModelEnum.PREPAY_MANDATE
try:
return BillingModelEnum(raw)
except ValueError:
return BillingModelEnum.PREPAY_MANDATE

View file

@ -96,6 +96,51 @@ registerModelLabels(
) )
class RoundMemory(BaseModel):
"""Persistent per-round memory for agent tool results, file refs, and decisions.
Stored after each agent round so that RAG can retrieve relevant context
even after the ConversationManager summarises older messages away.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
workflowId: str = Field(description="FK to the workflow")
roundNumber: int = Field(default=0, description="Agent round that produced this memory")
memoryType: str = Field(
description="Category: file_ref, tool_result, decision, data_source_ref"
)
key: str = Field(description="Dedup key, e.g. 'readFile:<fileId>' or 'plan'")
summary: str = Field(default="", description="Compact summary (max ~2000 chars)")
fullData: Optional[str] = Field(
default=None,
description="Full tool output when small enough (max ~8000 chars)",
)
fileIds: List[str] = Field(default_factory=list, description="Referenced file IDs")
embedding: Optional[List[float]] = Field(
default=None,
description="Embedding of summary for semantic retrieval",
json_schema_extra={"db_type": "vector(1536)"},
)
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
registerModelLabels(
"RoundMemory",
{"en": "Round Memory", "fr": "Mémoire de tour"},
{
"id": {"en": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"roundNumber": {"en": "Round Number", "fr": "Numéro de tour"},
"memoryType": {"en": "Memory Type", "fr": "Type de mémoire"},
"key": {"en": "Key", "fr": "Clé"},
"summary": {"en": "Summary", "fr": "Résumé"},
"fullData": {"en": "Full Data", "fr": "Données complètes"},
"fileIds": {"en": "File IDs", "fr": "IDs de fichier"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
"createdAt": {"en": "Created At", "fr": "Créé le"},
},
)
class WorkflowMemory(BaseModel): class WorkflowMemory(BaseModel):
"""Workflow-scoped key-value cache for entities and facts. """Workflow-scoped key-value cache for entities and facts.
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse.""" Extracted during agent rounds, persisted for cross-round and cross-workflow reuse."""

View file

@ -9,8 +9,8 @@ Multi-Tenant Design:
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt - Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
""" """
from typing import Optional from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict, model_validator
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from .datamodelUam import AuthAuthority from .datamodelUam import AuthAuthority
@ -23,6 +23,13 @@ class TokenStatus(str, Enum):
REVOKED = "revoked" REVOKED = "revoked"
class TokenPurpose(str, Enum):
"""Login/session token vs provider token bound to a UserConnection."""
AUTH_SESSION = "authSession"
DATA_CONNECTION = "dataConnection"
class Token(BaseModel): class Token(BaseModel):
""" """
Authentication Token model. Authentication Token model.
@ -38,6 +45,10 @@ class Token(BaseModel):
connectionId: Optional[str] = Field( connectionId: Optional[str] = Field(
None, description="ID of the connection this token belongs to" None, description="ID of the connection this token belongs to"
) )
tokenPurpose: Optional[TokenPurpose] = Field(
default=None,
description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection",
)
tokenAccess: str tokenAccess: str
tokenType: str = "bearer" tokenType: str = "bearer"
expiresAt: float = Field( expiresAt: float = Field(
@ -65,6 +76,22 @@ class Token(BaseModel):
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
@model_validator(mode="before")
@classmethod
def _defaultTokenPurposeFromDb(cls, data: Any) -> Any:
"""Missing tokenPurpose: connection rows → dataConnection; session rows → authSession."""
if isinstance(data, dict):
tp = data.get("tokenPurpose")
if tp is None or tp == "":
cid = data.get("connectionId")
purpose = (
TokenPurpose.DATA_CONNECTION.value
if cid
else TokenPurpose.AUTH_SESSION.value
)
data = {**data, "tokenPurpose": purpose}
return data
registerModelLabels( registerModelLabels(
"Token", "Token",
@ -74,6 +101,7 @@ registerModelLabels(
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"}, "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"}, "connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"tokenPurpose": {"en": "Token purpose", "de": "Token-Verwendung", "fr": "Usage du jeton"},
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"}, "tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"}, "tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},

View file

@ -1221,10 +1221,28 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
try: try:
balanceCheck = billingService.checkBalance(0.01) balanceCheck = billingService.checkBalance(0.01)
if not balanceCheck.allowed: if not balanceCheck.allowed:
raise BillingService.InsufficientBalanceException( mid = str(getattr(services, "mandateId", None) or mandateId or "")
currentBalance=balanceCheck.currentBalance or 0.0, from modules.datamodels.datamodelBilling import BillingModelEnum
requiredAmount=0.01, from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
message=f"Ungenuegendes Guthaben. Aktuell: CHF {balanceCheck.currentBalance:.2f}" maybeEmailMandatePoolExhausted,
)
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
u = getattr(services, "user", None)
ulabel = (
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
if u is not None else ""
)
maybeEmailMandatePoolExhausted(
mid,
str(getattr(u, "id", "") if u is not None else ""),
ulabel,
float(balanceCheck.currentBalance or 0.0),
0.01,
)
raise BillingService.InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
mid,
0.01,
) )
rbacAllowedProviders = billingService.getallowedProviders() rbacAllowedProviders = billingService.getallowedProviders()
if not rbacAllowedProviders: if not rbacAllowedProviders:

View file

@ -110,14 +110,15 @@ TEMPLATE_ROLES = [
{ {
"roleLabel": "workspace-admin", "roleLabel": "workspace-admin",
"description": { "description": {
"en": "Workspace Admin - Full access to AI workspace", "en": "Workspace Admin - All UI and API actions; data is always scoped to own records (same privacy as users)",
"de": "Workspace Admin - Vollzugriff auf AI Workspace", "de": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)",
"fr": "Administrateur Workspace - Acces complet au workspace AI" "fr": "Administrateur Workspace - Toute l'UI et les API; donnees limitees a ses propres enregistrements"
}, },
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, # DATA: never ALL in shared instances — every role (including admin) sees only _createdBy = self
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
] ]
}, },
] ]

View file

@ -8,6 +8,7 @@ SSE-based endpoints for the agent-driven AI Workspace.
import logging import logging
import json import json
import asyncio import asyncio
import uuid
from typing import Any, Dict, Optional, List from typing import Any, Dict, Optional, List
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request, UploadFile, File
@ -15,10 +16,14 @@ from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
from modules.interfaces import interfaceDbChat, interfaceDbManagement from modules.interfaces import interfaceDbChat, interfaceDbManagement
from modules.interfaces.interfaceAiObjects import AiObjects from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.core.serviceStreaming import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
from modules.shared.timeUtils import parseTimestamp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -242,8 +247,120 @@ def _buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str:
return "\n".join(parts) if found else "" return "\n".join(parts) if found else ""
def _workspaceFilesToChatDocuments(dbMgmt, fileIds: List[str]) -> List[Dict[str, Any]]:
"""Build ChatDocument payloads for workspace files referenced on the user message."""
documents: List[Dict[str, Any]] = []
for fid in fileIds or []:
try:
fr = dbMgmt.getFile(fid)
if not fr:
logger.warning(f"Workspace user message: file {fid} not found, skipping attachment record")
continue
fd = fr if isinstance(fr, dict) else fr.model_dump()
documents.append({
"id": str(uuid.uuid4()),
"fileId": fd.get("id") or fid,
"fileName": fd.get("fileName") or "file",
"fileSize": int(fd.get("fileSize") or 0),
"mimeType": fd.get("mimeType") or "application/octet-stream",
"roundNumber": 0,
"taskNumber": 0,
"actionNumber": 0,
})
except Exception as e:
logger.warning(f"Workspace user message: could not load file {fid}: {e}")
return documents
def _buildWorkspaceAttachmentLabel(chatService: Any, dataSourceIds: List[str], featureDataSourceIds: List[str]) -> str:
"""Short human-readable line for non-file attachments (data sources) on the user message."""
parts: List[str] = []
dsLabels: List[str] = []
for dsId in dataSourceIds or []:
try:
ds = chatService.getDataSource(dsId) if chatService and hasattr(chatService, "getDataSource") else None
if ds:
label = ds.get("label") or ds.get("path") or dsId[:8]
dsLabels.append(str(label))
except Exception as e:
logger.debug(f"Label for data source {dsId}: {e}")
if dsLabels:
parts.append("Datenquellen: " + ", ".join(dsLabels))
fdsLabels: List[str] = []
for fdsId in featureDataSourceIds or []:
try:
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
if records:
fds = records[0]
tbl = fds.get("tableName") or ""
lbl = fds.get("label") or tbl
fdsLabels.append(f"{tbl} ({lbl})".strip() if tbl else str(lbl))
except Exception as e:
logger.debug(f"Label for feature data source {fdsId}: {e}")
if fdsLabels:
parts.append("Feature-Daten: " + ", ".join(fdsLabels))
return " | ".join(parts)
def _workspaceMessageToClientDict(msg: Any) -> Dict[str, Any]:
"""Serialize ChatMessage (or dict) for workspace GET /messages including documents."""
if isinstance(msg, dict):
raw = dict(msg)
elif hasattr(msg, "model_dump"):
raw = msg.model_dump()
elif hasattr(msg, "dict"):
raw = msg.dict()
else:
raw = {
"id": getattr(msg, "id", None),
"workflowId": getattr(msg, "workflowId", None),
"role": getattr(msg, "role", ""),
"message": getattr(msg, "message", None) or getattr(msg, "content", None),
"publishedAt": getattr(msg, "publishedAt", None),
"sequenceNr": getattr(msg, "sequenceNr", None),
"documentsLabel": getattr(msg, "documentsLabel", None),
"documents": getattr(msg, "documents", None) or [],
}
if raw.get("message") is not None and raw.get("content") is None:
raw["content"] = raw["message"]
docs = raw.get("documents") or []
serialized_docs: List[Dict[str, Any]] = []
for doc in docs:
if isinstance(doc, dict):
serialized_docs.append(doc)
elif hasattr(doc, "model_dump"):
serialized_docs.append(doc.model_dump())
elif hasattr(doc, "dict"):
serialized_docs.append(doc.dict())
else:
serialized_docs.append({
"id": getattr(doc, "id", ""),
"messageId": getattr(doc, "messageId", ""),
"fileId": getattr(doc, "fileId", ""),
"fileName": getattr(doc, "fileName", ""),
"fileSize": getattr(doc, "fileSize", 0),
"mimeType": getattr(doc, "mimeType", ""),
"roundNumber": getattr(doc, "roundNumber", None),
"taskNumber": getattr(doc, "taskNumber", None),
"actionNumber": getattr(doc, "actionNumber", None),
"actionId": getattr(doc, "actionId", None),
})
raw["documents"] = serialized_docs
return raw
def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str) -> List[Dict[str, str]]: def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str) -> List[Dict[str, str]]:
"""Load prior messages from DB for follow-up context, excluding the current prompt.""" """Load prior messages from DB for follow-up context, excluding the current prompt.
File documents attached to user messages are serialized as a short
``[Attached files: ]`` block appended to the message content so the
agent sees which files a previous prompt referred to.
"""
try: try:
rawMessages = chatInterface.getMessages(workflowId) or [] rawMessages = chatInterface.getMessages(workflowId) or []
except Exception as e: except Exception as e:
@ -255,17 +372,55 @@ def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str)
if isinstance(msg, dict): if isinstance(msg, dict):
role = msg.get("role", "") role = msg.get("role", "")
content = msg.get("message", "") or msg.get("content", "") content = msg.get("message", "") or msg.get("content", "")
docs = msg.get("documents") or []
docsLabel = msg.get("documentsLabel") or ""
else: else:
role = getattr(msg, "role", "") role = getattr(msg, "role", "")
content = getattr(msg, "message", "") or getattr(msg, "content", "") content = getattr(msg, "message", "") or getattr(msg, "content", "")
if role in ("user", "assistant") and content: docs = getattr(msg, "documents", None) or []
history.append({"role": role, "content": content}) docsLabel = getattr(msg, "documentsLabel", "") or ""
if role not in ("user", "assistant"):
continue
if not content and not docs:
continue
enriched = content or ""
if role == "user" and docs:
fileParts = []
for doc in docs:
if isinstance(doc, dict):
fName = doc.get("fileName", "")
fId = doc.get("fileId", "")
fMime = doc.get("mimeType", "")
fSize = doc.get("fileSize", 0)
elif hasattr(doc, "fileName"):
fName = getattr(doc, "fileName", "")
fId = getattr(doc, "fileId", "")
fMime = getattr(doc, "mimeType", "")
fSize = getattr(doc, "fileSize", 0)
else:
continue
if fId or fName:
fileParts.append(f" - {fName} (id: {fId}, type: {fMime}, size: {fSize} bytes)")
if fileParts:
enriched += "\n\n[Attached files]\n" + "\n".join(fileParts)
if role == "user" and docsLabel:
enriched += f"\n[Attachments: {docsLabel}]"
if enriched.strip():
history.append({"role": role, "content": enriched})
if not history: if not history:
return [] return []
# Drop the last user message if it matches the current prompt (already added by the agent loop) # Drop the last user message if it matches the current prompt (already added by the agent loop)
if history[-1]["role"] == "user" and history[-1]["content"].strip() == currentPrompt.strip(): lastContent = history[-1].get("content", "").strip()
currentStripped = currentPrompt.strip()
if history[-1]["role"] == "user" and (
lastContent == currentStripped or lastContent.startswith(currentStripped)
):
history = history[:-1] history = history[:-1]
if history: if history:
@ -273,6 +428,36 @@ def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str)
return history return history
def _collectPriorFileIds(chatInterface, workflowId: str) -> List[str]:
"""Collect fileIds from all prior user messages in the workflow.
Returns a deduplicated list of file IDs so follow-up prompts
can reference files that were attached to earlier messages.
"""
try:
rawMessages = chatInterface.getMessages(workflowId) or []
except Exception:
return []
seen: set = set()
result: List[str] = []
for msg in rawMessages:
if isinstance(msg, dict):
role = msg.get("role", "")
docs = msg.get("documents") or []
else:
role = getattr(msg, "role", "")
docs = getattr(msg, "documents", None) or []
if role != "user":
continue
for doc in docs:
fid = doc.get("fileId", "") if isinstance(doc, dict) else getattr(doc, "fileId", "")
if fid and fid not in seen:
seen.add(fid)
result.append(fid)
return result
async def _deriveWorkflowName(prompt: str, aiService) -> str: async def _deriveWorkflowName(prompt: str, aiService) -> str:
"""Use AI to generate a concise workflow title from the user prompt.""" """Use AI to generate a concise workflow title from the user prompt."""
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
@ -341,11 +526,36 @@ async def streamWorkspaceStart(
queueId = f"workspace-{workflowId}" queueId = f"workspace-{workflowId}"
eventManager.create_queue(queueId) eventManager.create_queue(queueId)
chatInterface.createMessage({ dbMgmt = _getDbManagement(context, featureInstanceId=instanceId)
userDocuments = _workspaceFilesToChatDocuments(dbMgmt, userInput.fileIds or [])
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
svcCtx = ServiceCenterContext(
user=context.user,
mandate_id=mandateId or "",
feature_instance_id=instanceId,
workflow_id=workflowId,
)
chatSvc = getService("chat", svcCtx)
attachmentLabel = _buildWorkspaceAttachmentLabel(
chatSvc,
userInput.dataSourceIds or [],
userInput.featureDataSourceIds or [],
)
userMessageData: Dict[str, Any] = {
"workflowId": workflowId, "workflowId": workflowId,
"role": "user", "role": "user",
"message": userInput.prompt, "message": userInput.prompt,
}) }
if userDocuments:
userMessageData["documents"] = userDocuments
if attachmentLabel:
userMessageData["documentsLabel"] = attachmentLabel
chatInterface.createMessage(userMessageData)
agentTask = asyncio.ensure_future( agentTask = asyncio.ensure_future(
_runWorkspaceAgent( _runWorkspaceAgent(
@ -469,6 +679,18 @@ async def _runWorkspaceAgent(
conversationHistory = _loadConversationHistory(chatInterface, workflowId, prompt) conversationHistory = _loadConversationHistory(chatInterface, workflowId, prompt)
priorFileIds = _collectPriorFileIds(chatInterface, workflowId)
currentFileIdSet = set(fileIds or [])
mergedFileIds = list(fileIds or [])
for pf in priorFileIds:
if pf not in currentFileIdSet:
mergedFileIds.append(pf)
if len(mergedFileIds) > len(fileIds or []):
logger.info(
f"Merged {len(mergedFileIds) - len(fileIds or [])} prior file(s) into agent context "
f"(total: {len(mergedFileIds)}) for workflow {workflowId}"
)
accumulatedText = "" accumulatedText = ""
messagePersisted = False messagePersisted = False
@ -480,7 +702,7 @@ async def _runWorkspaceAgent(
async for event in agentService.runAgent( async for event in agentService.runAgent(
prompt=enrichedPrompt, prompt=enrichedPrompt,
fileIds=fileIds, fileIds=mergedFileIds,
workflowId=workflowId, workflowId=workflowId,
userLanguage=userLanguage, userLanguage=userLanguage,
conversationHistory=conversationHistory, conversationHistory=conversationHistory,
@ -581,12 +803,21 @@ async def _runWorkspaceAgent(
}) })
except Exception as e: except Exception as e:
logger.error(f"Workspace agent error: {e}", exc_info=True) if isinstance(e, InsufficientBalanceException):
await eventManager.emit_event(queueId, "error", { logger.warning(f"Workspace blocked by billing: {e.message}")
"type": "error", await eventManager.emit_event(queueId, "error", {
"content": str(e), "type": "error",
"workflowId": workflowId, "content": e.message,
}) "workflowId": workflowId,
"item": e.toClientDict(),
})
else:
logger.error(f"Workspace agent error: {e}", exc_info=True)
await eventManager.emit_event(queueId, "error", {
"type": "error",
"content": str(e),
"workflowId": workflowId,
})
finally: finally:
eventManager._unregister_agent_task(queueId) eventManager._unregister_agent_task(queueId)
@ -743,17 +974,14 @@ async def getWorkspaceMessages(
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
messages = chatInterface.getMessages(workflowId) or [] messages = chatInterface.getMessages(workflowId) or []
items = [] items = [_workspaceMessageToClientDict(m) for m in messages]
for msg in messages: items.sort(
if isinstance(msg, dict): key=lambda m: (
items.append(msg) parseTimestamp(m.get("publishedAt"), default=0) or 0,
else: m.get("sequenceNr") or 0,
items.append({ str(m.get("id") or ""),
"id": getattr(msg, "id", None), )
"role": getattr(msg, "role", ""), )
"content": getattr(msg, "message", "") or getattr(msg, "content", ""),
"createdAt": getattr(msg, "publishedAt", None) or getattr(msg, "createdAt", None),
})
return JSONResponse({"messages": items}) return JSONResponse({"messages": items})

View file

@ -1962,6 +1962,8 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
storeResources = [ storeResources = [
"resource.store.automation", "resource.store.automation",
"resource.store.teamsbot", "resource.store.teamsbot",
"resource.store.workspace",
"resource.store.commcoach",
] ]
storeRules = [] storeRules = []
@ -1998,7 +2000,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
def initRootMandateBilling(mandateId: str) -> None: def initRootMandateBilling(mandateId: str) -> None:
""" """
Initialize billing settings for root mandate. Initialize billing settings for root mandate.
Root mandate uses PREPAY_USER model with 10 CHF initial credit per user. Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only).
Creates billing accounts for ALL users regardless of billing model (for audit trail). Creates billing accounts for ALL users regardless of billing model (for audit trail).
Args: Args:
@ -2007,7 +2009,12 @@ def initRootMandateBilling(mandateId: str) -> None:
try: try:
from modules.interfaces.interfaceDbBilling import _getRootInterface from modules.interfaces.interfaceDbBilling import _getRootInterface
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum from modules.datamodels.datamodelBilling import (
BillingSettings,
BillingModelEnum,
DEFAULT_USER_CREDIT_CHF,
parseBillingModelFromStoredValue,
)
billingInterface = _getRootInterface() billingInterface = _getRootInterface()
appInterface = getAppRootInterface() appInterface = getAppRootInterface()
@ -2020,27 +2027,28 @@ def initRootMandateBilling(mandateId: str) -> None:
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER, billingModel=BillingModelEnum.PREPAY_USER,
defaultUserCredit=10.0, defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
blockOnZeroBalance=True,
notifyOnWarning=True notifyOnWarning=True
) )
billingInterface.createSettings(settings) billingInterface.createSettings(settings)
logger.info(f"Created billing settings for root mandate: PREPAY_USER with 10 CHF default credit") logger.info(
f"Created billing settings for root mandate: PREPAY_USER with {DEFAULT_USER_CREDIT_CHF} CHF default credit"
)
existingSettings = billingInterface.getSettings(mandateId) existingSettings = billingInterface.getSettings(mandateId)
# Always create user accounts for all users (audit trail) # Always create user accounts for all users (audit trail)
if existingSettings: if existingSettings:
billingModel = existingSettings.get("billingModel", "UNLIMITED") billingModel = parseBillingModelFromStoredValue(
if billingModel == BillingModelEnum.UNLIMITED.value: existingSettings.get("billingModel")
return # No accounts needed for UNLIMITED ).value
# Initial balance depends on billing model # Initial balance depends on billing model
if billingModel == BillingModelEnum.PREPAY_USER.value: if billingModel == BillingModelEnum.PREPAY_USER.value:
initialBalance = existingSettings.get("defaultUserCredit", 10.0) initialBalance = float(existingSettings.get("defaultUserCredit", 0.0))
else: else:
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget on pool initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account
userMandates = appInterface.getUserMandatesByMandate(mandateId) userMandates = appInterface.getUserMandatesByMandate(mandateId)
accountsCreated = 0 accountsCreated = 0

View file

@ -35,7 +35,7 @@ from modules.datamodels.datamodelRbac import (
Role, Role,
) )
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus, TokenPurpose
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.datamodels.datamodelMembership import ( from modules.datamodels.datamodelMembership import (
UserMandate, UserMandate,
@ -687,11 +687,17 @@ class AppObjects:
externalUsername: str = None, externalUsername: str = None,
externalEmail: str = None, externalEmail: str = None,
isSysAdmin: bool = False, isSysAdmin: bool = False,
addExternalIdentityConnection: bool = True,
) -> User: ) -> User:
""" """
Create a new user. Create a new user.
Note: Role assignment is done via createUserMandate(), not via User fields. Note: Role assignment is done via createUserMandate(), not via User fields.
Args:
addExternalIdentityConnection: If True (default) and externalId/externalUsername are set,
creates a UserConnection row. OAuth login-only flows should pass False (data connection
is created separately via /auth/connect).
""" """
try: try:
# Ensure username is a string # Ensure username is a string
@ -727,8 +733,9 @@ class AppObjects:
if not createdRecord or not createdRecord.get("id"): if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create user record") raise ValueError("Failed to create user record")
# Add external connection if provided # Optional: mirror external IdP identity into UserConnections (data/API OAuth).
if externalId and externalUsername: # Auth-only login (Google/MSFT JWT) must NOT create a connection — see OAuth split.
if addExternalIdentityConnection and externalId and externalUsername:
self.addUserConnection( self.addUserConnection(
createdRecord["id"], createdRecord["id"],
authenticationAuthority, authenticationAuthority,
@ -746,7 +753,7 @@ class AppObjects:
# Clear cache to ensure fresh data (already done above) # Clear cache to ensure fresh data (already done above)
# Assign new user to the root mandate with system 'viewer' role # Assign new user to the root mandate with mandate-instance 'user' role (no feature instances)
userId = createdUser[0]["id"] userId = createdUser[0]["id"]
self._assignUserToRootMandate(userId) self._assignUserToRootMandate(userId)
@ -815,7 +822,7 @@ class AppObjects:
def _assignUserToRootMandate(self, userId: str) -> None: def _assignUserToRootMandate(self, userId: str) -> None:
""" """
Assign a new user to the root mandate with the mandate-instance 'viewer' role. Assign a new user to the root mandate with the mandate-instance 'user' role.
This ensures every user has a base membership in the system mandate. This ensures every user has a base membership in the system mandate.
Uses the mandate-instance role (mandateId=rootMandateId), not the global template. Uses the mandate-instance role (mandateId=rootMandateId), not the global template.
@ -839,17 +846,17 @@ class AppObjects:
logger.debug(f"User {userId} already assigned to root mandate") logger.debug(f"User {userId} already assigned to root mandate")
return return
# Find the mandate-instance 'viewer' role (bound to this mandate, not a global template) # Mandate-instance 'user' role (bound to this mandate, not a global template)
mandateViewerRoles = self.db.getRecordset( mandateUserRoles = self.db.getRecordset(
Role, Role,
recordFilter={"roleLabel": "viewer", "mandateId": rootMandateId, "featureInstanceId": None} recordFilter={"roleLabel": "user", "mandateId": rootMandateId, "featureInstanceId": None}
) )
viewerRoleId = mandateViewerRoles[0].get("id") if mandateViewerRoles else None userRoleId = mandateUserRoles[0].get("id") if mandateUserRoles else None
roleIds = [viewerRoleId] if viewerRoleId else [] roleIds = [userRoleId] if userRoleId else []
self.createUserMandate(userId, rootMandateId, roleIds) self.createUserMandate(userId, rootMandateId, roleIds)
logger.info(f"Assigned user {userId} to root mandate with viewer role") logger.info(f"Assigned user {userId} to root mandate with user role")
except Exception as e: except Exception as e:
# Log but don't fail user creation # Log but don't fail user creation
@ -1641,8 +1648,9 @@ class AppObjects:
Ensure a user has a billing account for the mandate if billing is configured. Ensure a user has a billing account for the mandate if billing is configured.
User accounts are always created for all billing models (for audit trail). User accounts are always created for all billing models (for audit trail).
Initial balance depends on billing model: Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from settings - PREPAY_USER: defaultUserCredit from mandate BillingSettings when joining the root mandate (missing key => 0.0);
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on mandate pool) other mandates get 0.0.
- PREPAY_MANDATE: 0.0 on the user account (shared pool no per-user start credit)
Args: Args:
userId: User ID userId: User ID
@ -1650,7 +1658,7 @@ class AppObjects:
""" """
try: try:
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
from modules.datamodels.datamodelBilling import BillingModelEnum from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue
billingInterface = getBillingRootInterface() billingInterface = getBillingRootInterface()
settings = billingInterface.getSettings(mandateId) settings = billingInterface.getSettings(mandateId)
@ -1658,18 +1666,22 @@ class AppObjects:
if not settings: if not settings:
return # No billing configured for this mandate return # No billing configured for this mandate
billingModel = settings.get("billingModel", "UNLIMITED") billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.UNLIMITED.value:
return # No accounts needed for UNLIMITED
# Initial balance depends on billing model # Initial balance depends on billing model (start credit only on root mandate for PREPAY_USER)
if billingModel == BillingModelEnum.PREPAY_USER.value: rootMandateId = self._getRootMandateId()
initialBalance = settings.get("defaultUserCredit", 10.0) isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER:
initialBalance = (
float(settings.get("defaultUserCredit", 0.0))
if isRootMandate
else 0.0
)
else: else:
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget is on pool initialBalance = 0.0 # PREPAY_MANDATE: budget is on pool
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance) billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel}, initial={initialBalance} CHF)") logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel.value}, initial={initialBalance} CHF)")
except Exception as e: except Exception as e:
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}") logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
@ -1678,6 +1690,8 @@ class AppObjects:
""" """
Delete a UserMandate record (remove user from mandate). Delete a UserMandate record (remove user from mandate).
CASCADE will delete UserMandateRole entries. CASCADE will delete UserMandateRole entries.
Also removes FeatureAccess rows for any feature instances that belong to this mandate
(FeatureAccessRole rows cascade from FeatureAccess).
Args: Args:
userId: User ID userId: User ID
@ -1690,6 +1704,24 @@ class AppObjects:
existing = self.getUserMandate(userId, mandateId) existing = self.getUserMandate(userId, mandateId)
if not existing: if not existing:
return False return False
# Drop feature-instance memberships for instances under this mandate
instanceRows = self.db.getRecordset(
FeatureInstance,
recordFilter={"mandateId": mandateId}
)
for row in instanceRows:
instId = row.get("id")
if not instId:
continue
accessRows = self.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instId}
)
for acc in accessRows:
accId = acc.get("id")
if accId:
self.db.recordDelete(FeatureAccess, accId)
return self.db.recordDelete(UserMandate, existing.id) return self.db.recordDelete(UserMandate, existing.id)
except Exception as e: except Exception as e:
@ -2544,6 +2576,16 @@ class AppObjects:
"Access tokens cannot have connectionId - use saveConnectionToken instead" "Access tokens cannot have connectionId - use saveConnectionToken instead"
) )
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.AUTH_SESSION.value:
raise ValueError(
"saveAccessToken requires tokenPurpose=authSession (gateway session JWT)"
)
# Validate user context # Validate user context
if not self.currentUser or not self.currentUser.id: if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token storage") raise ValueError("No valid user context available for token storage")
@ -2566,6 +2608,7 @@ class AppObjects:
"userId": self.currentUser.id, "userId": self.currentUser.id,
"authority": token.authority, "authority": token.authority,
"connectionId": None, # Ensure we only delete access tokens "connectionId": None, # Ensure we only delete access tokens
"tokenPurpose": TokenPurpose.AUTH_SESSION.value,
}, },
) )
deleted_count = 0 deleted_count = 0
@ -2611,6 +2654,16 @@ class AppObjects:
"Connection tokens must have connectionId - use saveAccessToken instead" "Connection tokens must have connectionId - use saveAccessToken instead"
) )
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
raise ValueError(
"saveConnectionToken requires tokenPurpose=dataConnection (provider OAuth)"
)
# Validate user context # Validate user context
if not self.currentUser or not self.currentUser.id: if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token storage") raise ValueError("No valid user context available for token storage")
@ -2748,6 +2801,7 @@ class AppObjects:
authority: AuthAuthority, authority: AuthAuthority,
sessionId: str = None, sessionId: str = None,
mandateId: str = None, mandateId: str = None,
tokenPurpose: str = None,
) -> Optional[Token]: ) -> Optional[Token]:
"""Find an active access token by its id (jti) with optional session/tenant scoping.""" """Find an active access token by its id (jti) with optional session/tenant scoping."""
try: try:
@ -2763,6 +2817,8 @@ class AppObjects:
recordFilter["sessionId"] = sessionId recordFilter["sessionId"] = sessionId
if mandateId is not None: if mandateId is not None:
recordFilter["mandateId"] = mandateId recordFilter["mandateId"] = mandateId
if tokenPurpose is not None:
recordFilter["tokenPurpose"] = tokenPurpose
tokens = self.db.getRecordset(Token, recordFilter=recordFilter) tokens = self.db.getRecordset(Token, recordFilter=recordFilter)
if not tokens: if not tokens:
return None return None
@ -3405,6 +3461,10 @@ def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjec
instance = AppObjects(currentUser) instance = AppObjects(currentUser)
instance.setUserContext(currentUser, mandateId=effectiveMandateId) instance.setUserContext(currentUser, mandateId=effectiveMandateId)
_gatewayInterfaces[contextKey] = instance _gatewayInterfaces[contextKey] = instance
else:
# Re-apply user on every resolve: a prior code path (e.g. legacy logout) may have
# cleared currentUser on this cached singleton; OAuth/login must not see a stale context.
_gatewayInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId)
return _gatewayInterfaces[contextKey] return _gatewayInterfaces[contextKey]

View file

@ -23,7 +23,6 @@ from modules.datamodels.datamodelBilling import (
BillingSettings, BillingSettings,
StripeWebhookEvent, StripeWebhookEvent,
UsageStatistics, UsageStatistics,
BillingAddress,
BillingModelEnum, BillingModelEnum,
AccountTypeEnum, AccountTypeEnum,
TransactionTypeEnum, TransactionTypeEnum,
@ -31,10 +30,49 @@ from modules.datamodels.datamodelBilling import (
PeriodTypeEnum, PeriodTypeEnum,
BillingBalanceResponse, BillingBalanceResponse,
BillingCheckResult, BillingCheckResult,
parseBillingModelFromStoredValue,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _getAppDatabaseConnector() -> DatabaseConnector:
"""App DB connector (same config as UserMandate reads in this module)."""
return DatabaseConnector(
dbDatabase=APP_CONFIG.get("DB_DATABASE", "poweron_app"),
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbPort=int(APP_CONFIG.get("DB_PORT", "5432")),
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
)
def _getRootMandateIdFromAppDb(appDb: DatabaseConnector) -> Optional[str]:
"""Resolve root mandate id (name='root', isSystem=True) from app database."""
try:
rows = appDb.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
if rows:
rid = rows[0].get("id")
return str(rid) if rid is not None else None
except Exception as e:
logger.warning("Could not resolve root mandate id from app DB: %s", e)
return None
_cachedRootMandateId: Optional[str] = None
_rootMandateIdCacheResolved: bool = False
def _getCachedRootMandateId() -> Optional[str]:
"""Lazy-cached root mandate id (name=root, isSystem=True) for hot paths."""
global _cachedRootMandateId, _rootMandateIdCacheResolved
if not _rootMandateIdCacheResolved:
appDb = _getAppDatabaseConnector()
_cachedRootMandateId = _getRootMandateIdFromAppDb(appDb)
_rootMandateIdCacheResolved = True
return _cachedRootMandateId
# Singleton factory for BillingObjects instances # Singleton factory for BillingObjects instances
_billingInterfaces: Dict[str, "BillingObjects"] = {} _billingInterfaces: Dict[str, "BillingObjects"] = {}
@ -121,6 +159,8 @@ class BillingObjects:
""" """
Get billing settings for a mandate. Get billing settings for a mandate.
Normalizes billingModel for API (legacy UNLIMITED PREPAY_MANDATE) and persists once.
Args: Args:
mandateId: Mandate ID mandateId: Mandate ID
@ -132,7 +172,29 @@ class BillingObjects:
BillingSettings, BillingSettings,
recordFilter={"mandateId": mandateId} recordFilter={"mandateId": mandateId}
) )
return results[0] if results else None if not results:
return None
row = dict(results[0])
raw_bm = row.get("billingModel")
parsed = parseBillingModelFromStoredValue(raw_bm)
if str(raw_bm or "").strip().upper() == "UNLIMITED":
try:
self.updateSettings(
row["id"],
{"billingModel": BillingModelEnum.PREPAY_MANDATE.value},
)
logger.info(
"Migrated billing settings for mandate %s: UNLIMITED → PREPAY_MANDATE",
mandateId,
)
except Exception as mig_err:
logger.warning(
"Could not persist billing model migration for mandate %s: %s",
mandateId,
mig_err,
)
row["billingModel"] = parsed.value
return row
except Exception as e: except Exception as e:
logger.error(f"Error getting billing settings: {e}") logger.error(f"Error getting billing settings: {e}")
return None return None
@ -148,11 +210,6 @@ class BillingObjects:
Created settings dict Created settings dict
""" """
settingsDict = settings.model_dump(exclude_none=True) settingsDict = settings.model_dump(exclude_none=True)
# Handle nested BillingAddress
if settings.billingAddress:
settingsDict["billingAddress"] = settings.billingAddress.model_dump()
return self.db.recordCreate(BillingSettings, settingsDict) return self.db.recordCreate(BillingSettings, settingsDict)
def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@ -168,7 +225,7 @@ class BillingObjects:
""" """
return self.db.recordModify(BillingSettings, settingsId, updates) return self.db.recordModify(BillingSettings, settingsId, updates)
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.UNLIMITED) -> Dict[str, Any]: def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]:
""" """
Get or create billing settings for a mandate. Get or create billing settings for a mandate.
@ -186,10 +243,9 @@ class BillingObjects:
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=defaultModel, billingModel=defaultModel,
defaultUserCredit=10.0, defaultUserCredit=0.0,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
blockOnZeroBalance=True, notifyOnWarning=True,
notifyOnWarning=True
) )
return self.createSettings(settings) return self.createSettings(settings)
@ -365,7 +421,7 @@ class BillingObjects:
def ensureAllMandateSettingsExist(self) -> int: def ensureAllMandateSettingsExist(self) -> int:
""" """
Efficiently ensure all mandates have billing settings. Efficiently ensure all mandates have billing settings.
Creates default settings (PREPAY_USER) for mandates without settings. Creates default settings (PREPAY_MANDATE, 0 CHF) for mandates without settings.
Uses bulk queries to minimize database connections. Uses bulk queries to minimize database connections.
Returns: Returns:
@ -397,11 +453,10 @@ class BillingObjects:
# Create default billing settings # Create default billing settings
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER, billingModel=BillingModelEnum.PREPAY_MANDATE,
defaultUserCredit=10.0, defaultUserCredit=0.0,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
blockOnZeroBalance=True, notifyOnWarning=True,
notifyOnWarning=True
) )
self.createSettings(settings) self.createSettings(settings)
existingMandateIds.add(mandateId) # Track newly created existingMandateIds.add(mandateId) # Track newly created
@ -421,8 +476,8 @@ class BillingObjects:
Ensure all users across all mandates have billing accounts. Ensure all users across all mandates have billing accounts.
User accounts are always created regardless of billing model (for audit trail). User accounts are always created regardless of billing model (for audit trail).
Initial balance depends on billing model: Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from settings - PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on pool) - PREPAY_MANDATE: 0.0 (budget is on pool)
Uses bulk queries to minimize database connections. Uses bulk queries to minimize database connections.
@ -431,16 +486,23 @@ class BillingObjects:
""" """
try: try:
accountsCreated = 0 accountsCreated = 0
appDb = _getAppDatabaseConnector()
# Step 1: Get all billing settings (all models except UNLIMITED need user accounts) rootMandateId = _getCachedRootMandateId()
# Step 1: Get all billing settings (all mandates with settings get user accounts)
allSettings = self.db.getRecordset(BillingSettings) allSettings = self.db.getRecordset(BillingSettings)
billingMandates = {} # mandateId -> (billingModel, defaultCredit) billingMandates = {} # mandateId -> (billingModel, defaultCredit)
for s in allSettings: for s in allSettings:
billingModel = s.get("billingModel", BillingModelEnum.UNLIMITED.value) billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value
if billingModel == BillingModelEnum.UNLIMITED.value: mid = s.get("mandateId")
continue isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
defaultCredit = s.get("defaultUserCredit", 10.0) if billingModel == BillingModelEnum.PREPAY_USER.value else 0.0 if billingModel == BillingModelEnum.PREPAY_USER.value:
billingMandates[s.get("mandateId")] = (billingModel, defaultCredit) defaultCredit = (
float(s.get("defaultUserCredit", 0.0) or 0.0) if isRoot else 0.0
)
else:
defaultCredit = 0.0
billingMandates[mid] = (billingModel, defaultCredit)
if not billingMandates: if not billingMandates:
logger.debug("No billable mandates found, skipping account check") logger.debug("No billable mandates found, skipping account check")
@ -457,13 +519,6 @@ class BillingObjects:
existingAccountKeys.add(key) existingAccountKeys.add(key)
# Step 3: Get all user-mandate combinations from APP database # Step 3: Get all user-mandate combinations from APP database
appDb = DatabaseConnector(
dbDatabase=APP_CONFIG.get('DB_DATABASE', 'poweron_app'),
dbHost=APP_CONFIG.get('DB_HOST', 'localhost'),
dbPort=int(APP_CONFIG.get('DB_PORT', '5432')),
dbUser=APP_CONFIG.get('DB_USER'),
dbPassword=APP_CONFIG.get('DB_PASSWORD_SECRET')
)
allUserMandates = appDb.getRecordset( allUserMandates = appDb.getRecordset(
UserMandate, UserMandate,
recordFilter={"enabled": True} recordFilter={"enabled": True}
@ -711,69 +766,44 @@ class BillingObjects:
""" """
Check if there's sufficient balance for an operation. Check if there's sufficient balance for an operation.
Budget logic: - PREPAY_USER: user.balance >= estimatedCost
- PREPAY_USER: check user's own account balance - PREPAY_MANDATE: mandate pool balance >= estimatedCost
- PREPAY_MANDATE: check mandate pool balance (shared by all users)
- CREDIT_POSTPAY: check mandate pool credit limit
- UNLIMITED: always allowed
User accounts are always ensured to exist (for audit trail). User accounts are always ensured to exist (for audit trail).
Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
Args: Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
mandateId: Mandate ID
userId: User ID
estimatedCost: Estimated cost of the operation
Returns:
BillingCheckResult
""" """
settings = self.getSettings(mandateId) settings = self.getSettings(mandateId)
if not settings: if not settings:
return BillingCheckResult(allowed=True, billingModel=BillingModelEnum.UNLIMITED) billingModel = BillingModelEnum.PREPAY_MANDATE
defaultCredit = 0.0
else:
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0)
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) rootMandateId = _getCachedRootMandateId()
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
if billingModel == BillingModelEnum.UNLIMITED: if billingModel == BillingModelEnum.PREPAY_USER:
return BillingCheckResult(allowed=True, billingModel=billingModel) initialBalance = defaultCredit if isRootMandate else 0.0
else:
# Always ensure user account exists (for audit trail) initialBalance = 0.0
defaultCredit = settings.get("defaultUserCredit", 10.0)
initialBalance = defaultCredit if billingModel == BillingModelEnum.PREPAY_USER else 0.0
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance) self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
# Determine which balance to check based on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getUserAccount(mandateId, userId) account = self.getUserAccount(mandateId, userId)
currentBalance = account.get("balance", 0.0) if account else 0.0 currentBalance = account.get("balance", 0.0) if account else 0.0
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
elif billingModel == BillingModelEnum.CREDIT_POSTPAY:
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
creditLimit = poolAccount.get("creditLimit")
if creditLimit and abs(currentBalance) + estimatedCost > creditLimit:
return BillingCheckResult(
allowed=False,
reason="CREDIT_LIMIT_EXCEEDED",
currentBalance=currentBalance,
requiredAmount=estimatedCost,
billingModel=billingModel
)
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
else: else:
return BillingCheckResult(allowed=True, billingModel=billingModel) poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
# PREPAY models - check balance
if currentBalance < estimatedCost: if currentBalance < estimatedCost:
if settings.get("blockOnZeroBalance", True): return BillingCheckResult(
return BillingCheckResult( allowed=False,
allowed=False, reason="INSUFFICIENT_BALANCE",
reason="INSUFFICIENT_BALANCE", currentBalance=currentBalance,
currentBalance=currentBalance, requiredAmount=estimatedCost,
requiredAmount=estimatedCost, billingModel=billingModel,
billingModel=billingModel )
)
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel) return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
@ -800,7 +830,6 @@ class BillingObjects:
Balance is deducted from the appropriate account based on billing model: Balance is deducted from the appropriate account based on billing model:
- PREPAY_USER: deduct from user's own balance - PREPAY_USER: deduct from user's own balance
- PREPAY_MANDATE: deduct from mandate pool balance - PREPAY_MANDATE: deduct from mandate pool balance
- CREDIT_POSTPAY: deduct from mandate pool balance
""" """
if priceCHF <= 0: if priceCHF <= 0:
return None return None
@ -810,10 +839,7 @@ class BillingObjects:
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording") logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
return None return None
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.UNLIMITED:
return None
# Transaction is ALWAYS on the user's account (audit trail) # Transaction is ALWAYS on the user's account (audit trail)
userAccount = self.getOrCreateUserAccount(mandateId, userId) userAccount = self.getOrCreateUserAccount(mandateId, userId)
@ -838,12 +864,11 @@ class BillingObjects:
# Determine where to deduct balance # Determine where to deduct balance
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
# Deduct from user's own balance
return self.createTransaction(transaction) return self.createTransaction(transaction)
else: if billingModel == BillingModelEnum.PREPAY_MANDATE:
# PREPAY_MANDATE / CREDIT_POSTPAY: deduct from mandate pool
poolAccount = self.getOrCreateMandateAccount(mandateId) poolAccount = self.getOrCreateMandateAccount(mandateId)
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"]) return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
return None
# ========================================================================= # =========================================================================
# Workflow Cost Query # Workflow Cost Query
@ -865,18 +890,10 @@ class BillingObjects:
def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]: def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
""" """
Switch billing model with automatic budget migration. Switch billing model with budget migration logged as BillingTransactions.
MANDATE -> USER: pool balance is distributed equally to all user accounts. PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
USER -> MANDATE: all user balances are consolidated into the pool, user balances set to 0. PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
Args:
mandateId: Mandate ID
oldModel: Current billing model
newModel: New billing model
Returns:
Migration result dict with details
""" """
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0} result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
@ -884,47 +901,91 @@ class BillingObjects:
return result return result
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER: if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
# Pool -> distribute equally to users
poolAccount = self.getMandateAccount(mandateId) poolAccount = self.getMandateAccount(mandateId)
if poolAccount and poolAccount.get("balance", 0.0) > 0: userAccounts = self.db.getRecordset(
poolBalance = poolAccount["balance"] BillingAccount,
userAccounts = self.db.getRecordset( recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
BillingAccount, )
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value} poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
n = len(userAccounts)
if poolAccount and poolBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=poolBalance,
description="Model switch: distributed from mandate pool to user wallets",
referenceType=ReferenceTypeEnum.SYSTEM,
)
) )
if userAccounts: result["migratedAmount"] = poolBalance
perUser = poolBalance / len(userAccounts) if n > 0:
for acc in userAccounts: remaining = poolBalance
newBalance = acc.get("balance", 0.0) + perUser for i, acc in enumerate(userAccounts):
self.updateAccountBalance(acc["id"], newBalance) if i == n - 1:
self.updateAccountBalance(poolAccount["id"], 0.0) share = round(remaining, 4)
result["migratedAmount"] = poolBalance else:
result["userCount"] = len(userAccounts) share = round(poolBalance / n, 4)
remaining -= share
logger.info(f"Switched {mandateId} MANDATE->USER: distributed {result['migratedAmount']} CHF to {result['userCount']} users") if share > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=share,
description="Model switch: share from mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["userCount"] = n
logger.info(
"Switched %s MANDATE->USER: migrated %.4f CHF to %d user account(s) (transactions logged)",
mandateId,
result["migratedAmount"],
result["userCount"],
)
return result
elif oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE: if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
# Users -> consolidate into pool
userAccounts = self.db.getRecordset( userAccounts = self.db.getRecordset(
BillingAccount, BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value} recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
) )
totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts) totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
newPoolBalance = poolAccount.get("balance", 0.0) + totalUserBalance
self.updateAccountBalance(poolAccount["id"], newPoolBalance)
for acc in userAccounts: for acc in userAccounts:
self.updateAccountBalance(acc["id"], 0.0) b = acc.get("balance", 0.0)
if b > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=b,
description="Model switch: consolidated to mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
if totalUserBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=totalUserBalance,
description="Model switch: consolidated from user accounts",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["migratedAmount"] = totalUserBalance result["migratedAmount"] = totalUserBalance
result["userCount"] = len(userAccounts) result["userCount"] = len(userAccounts)
logger.info(
logger.info(f"Switched {mandateId} USER->MANDATE: consolidated {totalUserBalance} CHF from {len(userAccounts)} users into pool") "Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)",
mandateId,
totalUserBalance,
len(userAccounts),
)
return result
elif newModel == BillingModelEnum.PREPAY_MANDATE or newModel == BillingModelEnum.CREDIT_POSTPAY: if newModel == BillingModelEnum.PREPAY_MANDATE:
# Any -> MANDATE/CREDIT: ensure pool account exists
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0) self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
return result return result
@ -1027,8 +1088,6 @@ class BillingObjects:
Shows the effective available budget: Shows the effective available budget:
- PREPAY_USER: user's own account balance - PREPAY_USER: user's own account balance
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user) - PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
- CREDIT_POSTPAY: mandate pool balance
Args: Args:
userId: User ID userId: User ID
@ -1060,25 +1119,20 @@ class BillingObjects:
if not settings: if not settings:
continue continue
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.UNLIMITED:
continue
# Determine effective balance based on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getOrCreateUserAccount(mandateId, userId) account = self.getOrCreateUserAccount(mandateId, userId)
if not account: if not account:
continue continue
balance = account.get("balance", 0.0) balance = account.get("balance", 0.0)
warningThreshold = account.get("warningThreshold", 0.0) warningThreshold = account.get("warningThreshold", 0.0)
creditLimit = account.get("creditLimit") elif billingModel == BillingModelEnum.PREPAY_MANDATE:
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
poolAccount = self.getOrCreateMandateAccount(mandateId) poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount: if not poolAccount:
continue continue
balance = poolAccount.get("balance", 0.0) balance = poolAccount.get("balance", 0.0)
warningThreshold = poolAccount.get("warningThreshold", 0.0) warningThreshold = poolAccount.get("warningThreshold", 0.0)
creditLimit = poolAccount.get("creditLimit")
else: else:
continue continue
@ -1089,7 +1143,6 @@ class BillingObjects:
balance=balance, balance=balance,
warningThreshold=warningThreshold, warningThreshold=warningThreshold,
isWarning=balance <= warningThreshold, isWarning=balance <= warningThreshold,
creditLimit=creditLimit
)) ))
except Exception as e: except Exception as e:
logger.error(f"Error getting balances for user: {e}") logger.error(f"Error getting balances for user: {e}")
@ -1183,7 +1236,7 @@ class BillingObjects:
if not mandateId: if not mandateId:
continue continue
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get mandate info # Get mandate info
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
@ -1198,12 +1251,9 @@ class BillingObjects:
) )
userCount = len(userAccounts) userCount = len(userAccounts)
# Total balance depends on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
# Budget is distributed across user accounts
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts) totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billingModel == BillingModelEnum.PREPAY_MANDATE:
# Budget is in the mandate pool
poolAccount = self.getMandateAccount(mandateId) poolAccount = self.getMandateAccount(mandateId)
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0 totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
else: else:
@ -1215,9 +1265,8 @@ class BillingObjects:
"billingModel": billingModel.value, "billingModel": billingModel.value,
"totalBalance": totalBalance, "totalBalance": totalBalance,
"userCount": userCount, "userCount": userCount,
"defaultUserCredit": settings.get("defaultUserCredit", 0.0), "defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0), "warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
"blockOnZeroBalance": settings.get("blockOnZeroBalance", True)
}) })
except Exception as e: except Exception as e:

View file

@ -885,7 +885,7 @@ class ChatObjects:
"role": msg.get("role", "assistant"), "role": msg.get("role", "assistant"),
"status": msg.get("status", "step"), "status": msg.get("status", "step"),
"sequenceNr": msg.get("sequenceNr", 0), "sequenceNr": msg.get("sequenceNr", 0),
"publishedAt": msg.get("publishedAt", msg.get("timestamp", getUtcTimestamp())), "publishedAt": msg.get("publishedAt") or msg.get("_createdAt") or msg.get("timestamp") or 0,
"success": msg.get("success"), "success": msg.get("success"),
"actionId": msg.get("actionId"), "actionId": msg.get("actionId"),
"actionMethod": msg.get("actionMethod"), "actionMethod": msg.get("actionMethod"),
@ -899,8 +899,15 @@ class ChatObjects:
# Apply default sorting by publishedAt if no sort specified. # Apply default sorting by publishedAt if no sort specified.
# Use parseTimestamp to tolerate mixed DB types (float/string) on INT. # Use parseTimestamp to tolerate mixed DB types (float/string) on INT.
# Tie-break with sequenceNr then id so order matches conversation flow.
if pagination is None or not pagination.sort: if pagination is None or not pagination.sort:
messageDicts.sort(key=lambda x: parseTimestamp(x.get("publishedAt"), default=0)) messageDicts.sort(
key=lambda x: (
parseTimestamp(x.get("publishedAt"), default=0) or 0,
x.get("sequenceNr") or 0,
str(x.get("id") or ""),
)
)
# Apply filtering (if filters provided) # Apply filtering (if filters provided)
if pagination and pagination.filters: if pagination and pagination.filters:
@ -1039,6 +1046,15 @@ class ChatObjects:
if "actionNumber" not in messageData: if "actionNumber" not in messageData:
messageData["actionNumber"] = workflow.currentAction messageData["actionNumber"] = workflow.currentAction
if not messageData.get("publishedAt"):
messageData["publishedAt"] = getUtcTimestamp()
if not messageData.get("sequenceNr"):
existing = self._getRecordset(
ChatMessage, recordFilter={"workflowId": workflowId}
)
messageData["sequenceNr"] = len(existing) + 1
# Note: Chat data is user-owned, no mandate/featureInstance context stored # Note: Chat data is user-owned, no mandate/featureInstance context stored
# mandateId/featureInstanceId removed from ChatMessage model # mandateId/featureInstanceId removed from ChatMessage model

View file

@ -10,7 +10,7 @@ import logging
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.connectors.connectorDbPostgre import _get_cached_connector from modules.connectors.connectorDbPostgre import _get_cached_connector
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk, WorkflowMemory from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk, RoundMemory, WorkflowMemory
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -125,6 +125,58 @@ class KnowledgeObjects:
count += 1 count += 1
return count return count
# =========================================================================
# RoundMemory CRUD
# =========================================================================
def storeRoundMemory(self, memory: RoundMemory) -> Dict[str, Any]:
"""Create or update a RoundMemory entry (upsert by id)."""
data = memory.model_dump()
existing = self.db._loadRecord(RoundMemory, memory.id)
if existing:
return self.db.recordModify(RoundMemory, memory.id, data)
return self.db.recordCreate(RoundMemory, data)
def getRoundMemories(self, workflowId: str) -> List[Dict[str, Any]]:
"""Get all RoundMemory entries for a workflow, sorted by roundNumber."""
records = self.db.getRecordset(RoundMemory, recordFilter={"workflowId": workflowId})
records.sort(key=lambda r: r.get("roundNumber", 0))
return records
def getRoundMemoriesByType(
self, workflowId: str, memoryType: str
) -> List[Dict[str, Any]]:
"""Get RoundMemory entries filtered by type (e.g. 'file_ref')."""
return self.db.getRecordset(
RoundMemory, recordFilter={"workflowId": workflowId, "memoryType": memoryType}
)
def semanticSearchRoundMemory(
self,
queryVector: List[float],
workflowId: str,
limit: int = 10,
minScore: float = None,
) -> List[Dict[str, Any]]:
"""Semantic search across RoundMemory entries for a workflow."""
return self.db.semanticSearch(
modelClass=RoundMemory,
vectorColumn="embedding",
queryVector=queryVector,
limit=limit,
recordFilter={"workflowId": workflowId},
minScore=minScore,
)
def deleteRoundMemories(self, workflowId: str) -> int:
"""Delete all RoundMemory entries for a workflow. Returns count."""
entries = self.db.getRecordset(RoundMemory, recordFilter={"workflowId": workflowId})
count = 0
for entry in entries:
if self.db.recordDelete(RoundMemory, entry["id"]):
count += 1
return count
# ========================================================================= # =========================================================================
# WorkflowMemory CRUD # WorkflowMemory CRUD
# ========================================================================= # =========================================================================

View file

@ -17,7 +17,7 @@ Data Namespace Structure:
GROUP-Berechtigung: GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate) - data.uam.*: GROUP filtert nach Mandant (via UserMandate)
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext) - data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich _createdBy
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId - data.feature.*: GROUP filtert nach mandateId/featureInstanceId
""" """
@ -344,6 +344,20 @@ def buildRbacWhereClause(
# All records within the feature instance - only featureInstanceId filtering # All records within the feature instance - only featureInstanceId filtering
if readLevel == AccessLevel.ALL: if readLevel == AccessLevel.ALL:
# Chat / AI Workspace: even DATA read ALL must not list other users' rows in a
# shared featureInstance (stale RBAC rules or merged roles). Same as MY.
namespaceAll = TABLE_NAMESPACE.get(table, "system")
if featureInstanceId and namespaceAll == "chat":
userIdFieldAll = "_createdBy"
if table == "UserInDB":
userIdFieldAll = "id"
elif table == "UserConnection":
userIdFieldAll = "userId"
conditionsAll = list(baseConditions)
valuesAll = list(baseValues)
conditionsAll.append(f'"{userIdFieldAll}" = %s')
valuesAll.append(currentUser.id)
return {"condition": " AND ".join(conditionsAll), "values": valuesAll}
if baseConditions: if baseConditions:
return {"condition": " AND ".join(baseConditions), "values": baseValues} return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None return None
@ -379,6 +393,14 @@ def buildRbacWhereClause(
# But still apply featureInstanceId filter if provided # But still apply featureInstanceId filter if provided
if namespace in USER_OWNED_NAMESPACES: if namespace in USER_OWNED_NAMESPACES:
if baseConditions: if baseConditions:
# Shared feature instance: GROUP would otherwise only filter by featureInstanceId
# and expose every user's rows in that instance (e.g. ChatWorkflow).
if featureInstanceId and readLevel == AccessLevel.GROUP:
conditions = list(baseConditions)
values = list(baseValues)
conditions.append('"_createdBy" = %s')
values.append(currentUser.id)
return {"condition": " AND ".join(conditions), "values": values}
return {"condition": " AND ".join(baseConditions), "values": baseValues} return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None return None

View file

@ -22,9 +22,18 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService from modules.security.rbacCatalog import getCatalogService
from modules.routes.routeNotifications import create_access_change_notification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _feature_instance_display_name(instance: Any) -> str:
if instance is None:
return ""
if isinstance(instance, dict):
return str(instance.get("label") or instance.get("uiLabel") or instance.get("id", ""))
return str(getattr(instance, "label", None) or getattr(instance, "uiLabel", None) or getattr(instance, "id", ""))
router = APIRouter( router = APIRouter(
prefix="/api/features", prefix="/api/features",
tags=["Features"], tags=["Features"],
@ -1024,6 +1033,15 @@ def add_user_to_feature_instance(
f"User {context.user.id} added user {data.userId} to feature instance {instanceId} " f"User {context.user.id} added user {data.userId} to feature instance {instanceId} "
f"with roles {data.roleIds}" f"with roles {data.roleIds}"
) )
iname = _feature_instance_display_name(instance)
create_access_change_notification(
data.userId,
"Feature-Zugriff",
f"Sie haben Zugriff auf die Feature-Instanz «{iname}» erhalten.",
"feature_access",
instanceId,
)
return { return {
"featureAccessId": featureAccessId, "featureAccessId": featureAccessId,
@ -1104,6 +1122,15 @@ def remove_user_from_feature_instance(
logger.info( logger.info(
f"User {context.user.id} removed user {userId} from feature instance {instanceId}" f"User {context.user.id} removed user {userId} from feature instance {instanceId}"
) )
iname = _feature_instance_display_name(instance)
create_access_change_notification(
userId,
"Feature-Zugriff",
f"Ihr Zugriff auf die Feature-Instanz «{iname}» wurde entfernt.",
"feature_access",
instanceId,
)
return { return {
"message": "User access removed", "message": "User access removed",
@ -1197,6 +1224,15 @@ def update_feature_instance_user_roles(
logger.info( logger.info(
f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}" f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}"
) )
iname = _feature_instance_display_name(instance)
create_access_change_notification(
userId,
"Feature-Rollen geändert",
f"Ihre Rollen in der Feature-Instanz «{iname}» wurden angepasst.",
"feature_access",
instanceId,
)
return { return {
"featureAccessId": featureAccessId, "featureAccessId": featureAccessId,

View file

@ -28,7 +28,6 @@ from modules.datamodels.datamodelBilling import (
BillingAccount, BillingAccount,
BillingTransaction, BillingTransaction,
BillingSettings, BillingSettings,
BillingAddress,
BillingModelEnum, BillingModelEnum,
TransactionTypeEnum, TransactionTypeEnum,
ReferenceTypeEnum, ReferenceTypeEnum,
@ -37,6 +36,7 @@ from modules.datamodels.datamodelBilling import (
BillingStatisticsResponse, BillingStatisticsResponse,
BillingStatisticsChartData, BillingStatisticsChartData,
BillingCheckResult, BillingCheckResult,
parseBillingModelFromStoredValue,
) )
# Configure logger # Configure logger
@ -263,10 +263,8 @@ class BillingSettingsUpdate(BaseModel):
billingModel: Optional[BillingModelEnum] = None billingModel: Optional[BillingModelEnum] = None
defaultUserCredit: Optional[float] = Field(None, ge=0) defaultUserCredit: Optional[float] = Field(None, ge=0)
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100) warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
blockOnZeroBalance: Optional[bool] = None
notifyOnWarning: Optional[bool] = None notifyOnWarning: Optional[bool] = None
notifyEmails: Optional[List[str]] = None notifyEmails: Optional[List[str]] = None
billingAddress: Optional[BillingAddress] = None
class TransactionResponse(BaseModel): class TransactionResponse(BaseModel):
@ -295,7 +293,6 @@ class AccountSummary(BaseModel):
userId: Optional[str] userId: Optional[str]
accountType: str accountType: str
balance: float balance: float
creditLimit: Optional[float]
warningThreshold: float warningThreshold: float
enabled: bool enabled: bool
@ -323,7 +320,6 @@ class MandateBalanceResponse(BaseModel):
userCount: int userCount: int
defaultUserCredit: float defaultUserCredit: float
warningThresholdPercent: float warningThresholdPercent: float
blockOnZeroBalance: bool
class UserBalanceResponse(BaseModel): class UserBalanceResponse(BaseModel):
@ -427,12 +423,12 @@ def _creditStripeSessionIfNeeded(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found") raise HTTPException(status_code=404, detail="Billing settings not found")
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billing_model == BillingModelEnum.PREPAY_USER: if billing_model == BillingModelEnum.PREPAY_USER:
if not user_id: if not user_id:
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER") raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0) account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billing_model == BillingModelEnum.PREPAY_MANDATE:
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0) account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
else: else:
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}") raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
@ -529,11 +525,10 @@ def getBalanceForMandate(
return BillingBalanceResponse( return BillingBalanceResponse(
mandateId=targetMandateId, mandateId=targetMandateId,
mandateName=mandateName, mandateName=mandateName,
billingModel=checkResult.billingModel or BillingModelEnum.UNLIMITED, billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE,
balance=checkResult.currentBalance or 0.0, balance=checkResult.currentBalance or 0.0,
warningThreshold=0.0, # TODO: Get from account warningThreshold=0.0, # TODO: Get from account
isWarning=False, isWarning=False,
creditLimit=None
) )
except Exception as e: except Exception as e:
@ -622,7 +617,7 @@ def getStatistics(
costByFeature={} costByFeature={}
) )
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Transactions are always on user accounts (audit trail) # Transactions are always on user accounts (audit trail)
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id) account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
@ -750,8 +745,12 @@ def createOrUpdateSettings(
if updates: if updates:
# Check if billing model is changing - trigger budget migration # Check if billing model is changing - trigger budget migration
if "billingModel" in updates: if "billingModel" in updates:
oldModel = BillingModelEnum(existingSettings.get("billingModel", BillingModelEnum.UNLIMITED.value)) oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel"))
newModel = BillingModelEnum(updates["billingModel"]) if isinstance(updates["billingModel"], str) else updates["billingModel"] newModel = (
BillingModelEnum(updates["billingModel"])
if isinstance(updates["billingModel"], str)
else updates["billingModel"]
)
if oldModel != newModel: if oldModel != newModel:
migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel) migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}") logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
@ -764,13 +763,27 @@ def createOrUpdateSettings(
newSettings = BillingSettings( newSettings = BillingSettings(
mandateId=targetMandateId, mandateId=targetMandateId,
billingModel=settingsUpdate.billingModel or BillingModelEnum.UNLIMITED, billingModel=(
defaultUserCredit=settingsUpdate.defaultUserCredit or 10.0, settingsUpdate.billingModel
warningThresholdPercent=settingsUpdate.warningThresholdPercent or 10.0, if settingsUpdate.billingModel is not None
blockOnZeroBalance=settingsUpdate.blockOnZeroBalance if settingsUpdate.blockOnZeroBalance is not None else True, else BillingModelEnum.PREPAY_MANDATE
notifyOnWarning=settingsUpdate.notifyOnWarning if settingsUpdate.notifyOnWarning is not None else True, ),
defaultUserCredit=(
settingsUpdate.defaultUserCredit
if settingsUpdate.defaultUserCredit is not None
else 0.0
),
warningThresholdPercent=(
settingsUpdate.warningThresholdPercent
if settingsUpdate.warningThresholdPercent is not None
else 10.0
),
notifyOnWarning=(
settingsUpdate.notifyOnWarning
if settingsUpdate.notifyOnWarning is not None
else True
),
notifyEmails=settingsUpdate.notifyEmails or [], notifyEmails=settingsUpdate.notifyEmails or [],
billingAddress=settingsUpdate.billingAddress
) )
return billingInterface.createSettings(newSettings) return billingInterface.createSettings(newSettings)
@ -803,7 +816,7 @@ def addCredit(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Validate request based on billing model # Validate request based on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
@ -816,7 +829,7 @@ def addCredit(
creditRequest.userId, creditRequest.userId,
initialBalance=0.0 initialBalance=0.0
) )
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billingModel == BillingModelEnum.PREPAY_MANDATE:
# Create mandate-level account if needed and add credit # Create mandate-level account if needed and add credit
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0) account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
else: else:
@ -866,7 +879,7 @@ def createCheckoutSession(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
if not checkoutRequest.userId: if not checkoutRequest.userId:
@ -875,7 +888,7 @@ def createCheckoutSession(
raise HTTPException(status_code=403, detail="Users can only load credit to their own account") raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
if not _isMemberOfMandate(ctx, targetMandateId): if not _isMemberOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=403, detail="User is not a member of this mandate") raise HTTPException(status_code=403, detail="User is not a member of this mandate")
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billingModel == BillingModelEnum.PREPAY_MANDATE:
if not _isAdminOfMandate(ctx, targetMandateId): if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit") raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
else: else:
@ -933,7 +946,7 @@ def confirmCheckoutSession(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found") raise HTTPException(status_code=404, detail="Billing settings not found")
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billing_model == BillingModelEnum.PREPAY_USER: if billing_model == BillingModelEnum.PREPAY_USER:
if not user_id: if not user_id:
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER") raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
@ -941,7 +954,7 @@ def confirmCheckoutSession(
raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions") raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions")
if not _isMemberOfMandate(ctx, mandate_id): if not _isMemberOfMandate(ctx, mandate_id):
raise HTTPException(status_code=403, detail="User is not a member of this mandate") raise HTTPException(status_code=403, detail="User is not a member of this mandate")
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billing_model == BillingModelEnum.PREPAY_MANDATE:
if not _isAdminOfMandate(ctx, mandate_id): if not _isAdminOfMandate(ctx, mandate_id):
raise HTTPException(status_code=403, detail="Mandate admin role required") raise HTTPException(status_code=403, detail="Mandate admin role required")
else: else:
@ -1041,7 +1054,6 @@ def getAccounts(
userId=acc.get("userId"), userId=acc.get("userId"),
accountType=acc.get("accountType"), accountType=acc.get("accountType"),
balance=acc.get("balance", 0.0), balance=acc.get("balance", 0.0),
creditLimit=acc.get("creditLimit"),
warningThreshold=acc.get("warningThreshold", 0.0), warningThreshold=acc.get("warningThreshold", 0.0),
enabled=acc.get("enabled", True) enabled=acc.get("enabled", True)
)) ))

View file

@ -16,6 +16,7 @@ from fastapi import status
import logging import logging
import json import json
import math import math
from urllib.parse import quote
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
@ -445,24 +446,12 @@ def connect_service(
detail="Connection not found" detail="Connection not found"
) )
# Initiate OAuth flow with state=connect # Data-app OAuth (JWT state issued server-side in /auth/connect)
auth_url = None auth_url = None
if connection.authority == AuthAuthority.MSFT: if connection.authority == AuthAuthority.MSFT:
# Use the same login endpoint with state=connect to ensure account selection auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}"
# Include current user ID in state
state_data = {
"type": "connect",
"connectionId": connectionId,
"userId": currentUser.id # Add current user ID
}
auth_url = f"/api/msft/login?state={json.dumps(state_data)}"
elif connection.authority == AuthAuthority.GOOGLE: elif connection.authority == AuthAuthority.GOOGLE:
state_data = { auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}"
"type": "connect",
"connectionId": connectionId,
"userId": currentUser.id # Add current user ID
}
auth_url = f"/api/google/login?state={json.dumps(state_data)}"
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -21,6 +21,7 @@ from modules.auth import limiter, requireSysAdminRole, getRequestContext, Reques
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbApp as interfaceDbApp import modules.interfaces.interfaceDbApp as interfaceDbApp
from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRootInterface
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
@ -29,6 +30,7 @@ from modules.datamodels.datamodelUam import Mandate, User
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeNotifications import create_access_change_notification
# ============================================================================= # =============================================================================
@ -247,6 +249,15 @@ def create_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create mandate" detail="Failed to create mandate"
) )
try:
billingInterface = _getBillingRootInterface()
billingInterface.getOrCreateSettings(str(newMandate.id))
logger.debug(f"Ensured billing settings for new mandate {newMandate.id}")
except Exception as billingErr:
logger.warning(
f"Could not create default billing settings for mandate {newMandate.id}: {billingErr}"
)
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}") logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
@ -612,6 +623,15 @@ def add_user_to_mandate(
f"User {context.user.id} added user {data.targetUserId} to mandate {targetMandateId} " f"User {context.user.id} added user {data.targetUserId} to mandate {targetMandateId} "
f"with roles {data.roleIds}" f"with roles {data.roleIds}"
) )
mname = _mandate_display_name(mandate)
create_access_change_notification(
data.targetUserId,
"Mandantenzugriff",
f"Sie wurden dem Mandanten «{mname}» hinzugefügt.",
"mandate_access",
targetMandateId,
)
return UserMandateResponse( return UserMandateResponse(
id=str(userMandate.id), # UserMandate ID as primary key id=str(userMandate.id), # UserMandate ID as primary key
@ -696,6 +716,15 @@ def remove_user_from_mandate(
) )
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}") logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}")
mname = _mandate_display_name(mandate)
create_access_change_notification(
targetUserId,
"Mandantenzugriff",
f"Sie wurden aus dem Mandanten «{mname}» entfernt.",
"mandate_access",
targetMandateId,
)
return {"message": "User removed from mandate", "userId": targetUserId} return {"message": "User removed from mandate", "userId": targetUserId}
@ -791,6 +820,16 @@ def update_user_roles_in_mandate(
f"User {context.user.id} updated roles for user {targetUserId} " f"User {context.user.id} updated roles for user {targetUserId} "
f"in mandate {targetMandateId} to {roleIds}" f"in mandate {targetMandateId} to {roleIds}"
) )
mandate_meta = rootInterface.getMandate(targetMandateId)
mname = _mandate_display_name(mandate_meta)
create_access_change_notification(
targetUserId,
"Mandantenrollen geändert",
f"Ihre Rollen im Mandanten «{mname}» wurden angepasst.",
"mandate_access",
targetMandateId,
)
return UserMandateResponse( return UserMandateResponse(
id=str(membership.id), # UserMandate ID as primary key id=str(membership.id), # UserMandate ID as primary key
@ -814,6 +853,28 @@ def update_user_roles_in_mandate(
# Helper Functions # Helper Functions
# ============================================================================= # =============================================================================
def _mandate_display_name(mandate: Any) -> str:
"""Human-readable mandate label for notifications."""
if mandate is None:
return ""
if isinstance(mandate, dict):
if mandate.get("label"):
return str(mandate["label"])
name = mandate.get("name")
if isinstance(name, dict):
return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
return str(name or mandate.get("id", ""))
label = getattr(mandate, "label", None)
if label:
return str(label)
name = getattr(mandate, "name", None)
if isinstance(name, dict):
return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
if name is not None:
return str(name)
return str(getattr(mandate, "id", ""))
def _getAdminMandateIds(context: RequestContext) -> List[str]: def _getAdminMandateIds(context: RequestContext) -> List[str]:
""" """
Get list of mandate IDs where the user has the admin role. Get list of mandate IDs where the user has the admin role.

View file

@ -89,6 +89,31 @@ def _createNotification(
return notification return notification
def create_access_change_notification(
userId: str,
title: str,
message: str,
reference_type: str,
reference_id: Optional[str] = None,
) -> None:
"""
In-app notification for mandate/feature access changes (triggers client nav refresh).
Failures are logged only so RBAC mutations still succeed.
"""
try:
_createNotification(
userId=userId,
notificationType=NotificationType.SYSTEM,
title=title,
message=message,
referenceType=reference_type,
referenceId=reference_id,
icon="shield",
)
except Exception as e:
logger.warning(f"Could not create access-change notification for user {userId}: {e}")
def createInvitationNotification( def createInvitationNotification(
userId: str, userId: str,
invitationId: str, invitationId: str,

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@ from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface 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
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -164,6 +164,7 @@ def login(
id=jti, id=jti,
userId=user.id, userId=user.id,
authority=AuthAuthority.LOCAL, authority=AuthAuthority.LOCAL,
tokenPurpose=TokenPurpose.AUTH_SESSION,
tokenAccess=access_token, tokenAccess=access_token,
tokenType="bearer", tokenType="bearer",
expiresAt=expires_at.timestamp(), expiresAt=expires_at.timestamp(),

File diff suppressed because it is too large Load diff

View file

@ -110,15 +110,38 @@ def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] |
return accesses[0] if accesses else None return accesses[0] if accesses else None
def _findUserRole(rootInterface, instanceId: str, featureCode: str) -> str | None: def _findStoreUserRoleId(
"""Find the user-level role for a feature instance.""" 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) instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
userRoleLabel = f"{featureCode}-user" 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:
continue
low = lbl.lower()
if "admin" in low:
continue
if lbl.endswith("-user") and lbl in labelToId:
return labelToId[lbl]
for role in instanceRoles: for role in instanceRoles:
if role.roleLabel == userRoleLabel: low = (role.roleLabel or "").lower()
return str(role.id) if "admin" in low:
for role in instanceRoles: continue
if "user" in role.roleLabel.lower() and "admin" not in role.roleLabel.lower(): if "user" in low:
return str(role.id) return str(role.id)
return None return None
@ -249,13 +272,25 @@ def activateStoreFeature(
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump()) createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
featureAccessId = createdAccess.get("id") featureAccessId = createdAccess.get("id")
userRoleId = _findUserRole(rootInterface, instanceId, featureCode) userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
if userRoleId: if not userRoleId:
featureAccessRole = FeatureAccessRole( db.recordDelete(FeatureAccess, featureAccessId)
featureAccessId=featureAccessId, logger.error(
roleId=userRoleId f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'"
) )
db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump()) 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."
),
)
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=userRoleId
)
db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
logger.info( logger.info(
f"User {userId} activated store feature '{featureCode}' " f"User {userId} activated store feature '{featureCode}' "

View file

@ -8,7 +8,7 @@ Core service - not requested by features directly.
import logging import logging
from typing import Optional, Callable, Any from typing import Optional, Callable, Any
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import TokenManager from modules.auth import TokenManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,6 +34,16 @@ class SecurityService:
token = self._interfaceDbApp.getConnectionToken(connectionId) token = self._interfaceDbApp.getConnectionToken(connectionId)
if not token: if not token:
return None return None
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning(
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
)
return None
return self._tokenManager.ensureFreshToken( return self._tokenManager.ensureFreshToken(
token, token,
secondsBeforeExpiry=secondsBeforeExpiry, secondsBeforeExpiry=secondsBeforeExpiry,

View file

@ -22,6 +22,9 @@ from modules.serviceCenter.services.serviceAgent.conversationManager import (
) )
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.jsonUtils import closeJsonStructures from modules.shared.jsonUtils import closeJsonStructures
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,6 +43,8 @@ async def runAgentLoop(
aiCallStreamFn: Callable = None, aiCallStreamFn: Callable = None,
userLanguage: str = "", userLanguage: str = "",
conversationHistory: List[Dict[str, Any]] = None, conversationHistory: List[Dict[str, Any]] = None,
persistRoundMemoryFn: Callable[..., Awaitable[None]] = None,
getExternalMemoryKeysFn: Callable[[], List[str]] = None,
) -> AsyncGenerator[AgentEvent, None]: ) -> AsyncGenerator[AgentEvent, None]:
"""Run the agent loop. Yields AgentEvent for each step (SSE-ready). """Run the agent loop. Yields AgentEvent for each step (SSE-ready).
@ -56,6 +61,9 @@ async def runAgentLoop(
mandateId: Mandate ID for RAG scoping mandateId: Mandate ID for RAG scoping
userLanguage: ISO 639-1 language code for agent responses userLanguage: ISO 639-1 language code for agent responses
conversationHistory: Prior messages [{role, content/message}] for follow-up context conversationHistory: Prior messages [{role, content/message}] for follow-up context
persistRoundMemoryFn: Optional callback to persist round memories after tool execution
getExternalMemoryKeysFn: Optional callback that returns RoundMemory keys for
this workflow, used by summarization to de-duplicate persisted facts
""" """
state = AgentState(workflowId=workflowId, maxRounds=config.maxRounds) state = AgentState(workflowId=workflowId, maxRounds=config.maxRounds)
trace = AgentTrace( trace = AgentTrace(
@ -76,7 +84,7 @@ async def runAgentLoop(
conversation = ConversationManager(systemPrompt) conversation = ConversationManager(systemPrompt)
if conversationHistory: if conversationHistory:
conversation.loadHistory(conversationHistory) conversation.loadHistory(conversationHistory)
conversation.addUserMessage(prompt) conversation.addUserMessage(prompt, isCurrentPrompt=True)
while state.status == AgentStatusEnum.RUNNING and state.currentRound < state.maxRounds: while state.status == AgentStatusEnum.RUNNING and state.currentRound < state.maxRounds:
await asyncio.sleep(0) await asyncio.sleep(0)
@ -139,7 +147,15 @@ async def runAgentLoop(
state.totalAiCalls += 1 state.totalAiCalls += 1
return resp.content return resp.content
await conversation.summarize(state.currentRound, _summarizeCall) memKeys: List[str] = []
if getExternalMemoryKeysFn:
try:
memKeys = getExternalMemoryKeysFn()
except Exception:
pass
await conversation.summarize(
state.currentRound, _summarizeCall, externalMemoryKeys=memKeys or None
)
# AI call # AI call
aiRequest = AiCallRequest( aiRequest = AiCallRequest(
@ -175,6 +191,18 @@ async def runAgentLoop(
else: else:
aiResponse = await aiCallFn(aiRequest) aiResponse = await aiCallFn(aiRequest)
except InsufficientBalanceException as e:
logger.warning(
f"Insufficient balance in round {state.currentRound} (mandate={mandateId}): {e.message}"
)
state.status = AgentStatusEnum.ERROR
state.abortReason = e.message
yield AgentEvent(
type=AgentEventTypeEnum.ERROR,
content=e.message,
data=e.toClientDict(),
)
break
except Exception as e: except Exception as e:
logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True) logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True)
state.status = AgentStatusEnum.ERROR state.status = AgentStatusEnum.ERROR
@ -292,6 +320,18 @@ async def runAgentLoop(
] ]
conversation.addToolResults(toolResultMessages) conversation.addToolResults(toolResultMessages)
# Persist round memories (file refs, tool results, decisions)
if persistRoundMemoryFn:
try:
await persistRoundMemoryFn(
toolCalls=toolCalls,
results=results,
textContent=textContent,
roundNumber=state.currentRound,
)
except Exception as memErr:
logger.warning(f"RoundMemory persist failed (non-blocking): {memErr}")
roundLog.durationMs = int((time.time() - roundStartTime) * 1000) roundLog.durationMs = int((time.time() - roundStartTime) * 1000)
trace.rounds.append(roundLog) trace.rounds.append(roundLog)
@ -486,6 +526,80 @@ def _buildProgressSummary(state: AgentState, reason: str) -> str:
) )
_FILE_REF_TOOLS = {"readFile", "readContentObjects", "describeImage", "listFiles"}
_DATA_SOURCE_TOOLS = {"browseDataSource", "searchDataSource", "downloadFromDataSource"}
_DECISION_TOOLS = {"writeFile", "replaceInFile"}
def _classifyToolResult(
tc: ToolCallRequest, result: ToolResult
) -> Optional[Dict[str, Any]]:
"""Classify a successful tool result into a RoundMemory dict.
Returns a dict with keys {memoryType, key, summary, fullData, fileIds}
or None if the result is not worth persisting.
"""
name = tc.name
data = result.data or ""
if len(data) < 50:
return None
truncSummary = data[:2000]
fullData = data if len(data) < 8000 else None
fileId = tc.args.get("fileId", "")
fileIds = [fileId] if fileId else []
if name in _FILE_REF_TOOLS:
return {
"memoryType": "file_ref",
"key": f"{name}:{fileId}" if fileId else name,
"summary": truncSummary,
"fullData": fullData,
"fileIds": fileIds,
}
if name in _DATA_SOURCE_TOOLS:
dsId = tc.args.get("dataSourceId", "") or tc.args.get("featureDataSourceId", "")
path = tc.args.get("path", "")
return {
"memoryType": "data_source_ref",
"key": f"{name}:{dsId}:{path}" if dsId else name,
"summary": truncSummary,
"fullData": fullData,
"fileIds": fileIds,
}
if name in _DECISION_TOOLS:
return {
"memoryType": "decision",
"key": f"{name}:{fileId}" if fileId else name,
"summary": truncSummary,
"fullData": None,
"fileIds": fileIds,
}
if name == "queryFeatureInstance":
return {
"memoryType": "tool_result",
"key": f"queryFeatureInstance:{tc.args.get('query', '')[:60]}",
"summary": truncSummary,
"fullData": fullData,
"fileIds": [],
}
if len(data) > 500:
return {
"memoryType": "tool_result",
"key": f"{name}:{tc.id}",
"summary": truncSummary,
"fullData": fullData,
"fileIds": fileIds,
}
return None
_ARTIFACT_TOOLS = {"writeFile", "replaceInFile", "deleteFile", "renameFile", "copyFile", _ARTIFACT_TOOLS = {"writeFile", "replaceInFile", "deleteFile", "renameFile", "copyFile",
"createFolder", "deleteFolder", "renderDocument", "generateImage"} "createFolder", "deleteFolder", "renderDocument", "generateImage"}

View file

@ -10,9 +10,9 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FIRST_SUMMARY_ROUND = 4 FIRST_SUMMARY_ROUND = 6
META_SUMMARY_ROUND = 7 META_SUMMARY_ROUND = 10
KEEP_RECENT_MESSAGES = 4 KEEP_RECENT_MESSAGES = 6
MAX_ESTIMATED_TOKENS = 60000 MAX_ESTIMATED_TOKENS = 60000
_MAX_HISTORY_MESSAGES = 40 _MAX_HISTORY_MESSAGES = 40
_MAX_HISTORY_MSG_CHARS = 12000 _MAX_HISTORY_MSG_CHARS = 12000
@ -22,9 +22,12 @@ class ConversationManager:
"""Manages the conversation history and context window for agent runs. """Manages the conversation history and context window for agent runs.
Progressive summarization strategy: Progressive summarization strategy:
- Rounds 1-3: full conversation retained - Rounds 1-5: full conversation retained
- Round 4+: older messages compressed into a running summary - Round 6+: older messages compressed into a running summary
- Round 7+: meta-summary replaces prior summaries - Round 10+: meta-summary replaces prior summaries
Long-term facts (file refs, tool results, decisions) are persisted
externally in RoundMemory and retrieved via RAG, so the summary
can focus on reasoning and relationships.
Supports RAG context injection before each round via injectRagContext.""" Supports RAG context injection before each round via injectRagContext."""
def __init__(self, systemPrompt: str): def __init__(self, systemPrompt: str):
@ -69,9 +72,19 @@ class ConversationManager:
for msg in self._messages for msg in self._messages
] ]
def addUserMessage(self, content: str): def addUserMessage(self, content: str, isCurrentPrompt: bool = False):
"""Add a user message.""" """Add a user message.
self._messages.append({"role": "user", "content": content})
Args:
content: Message text.
isCurrentPrompt: If True, this message is the user's current
task prompt and will never be removed by progressive
summarization.
"""
msg: Dict[str, Any] = {"role": "user", "content": content}
if isCurrentPrompt:
msg["_isCurrentPrompt"] = True
self._messages.append(msg)
def addAssistantMessage(self, content: str, toolCalls: List[Dict[str, Any]] = None): def addAssistantMessage(self, content: str, toolCalls: List[Dict[str, Any]] = None):
"""Add an assistant message, optionally with tool calls.""" """Add an assistant message, optionally with tool calls."""
@ -135,8 +148,8 @@ class ConversationManager:
"""Check if progressive summarization should be triggered. """Check if progressive summarization should be triggered.
Triggers: Triggers:
- At round FIRST_SUMMARY_ROUND (4) if not yet summarized - At round FIRST_SUMMARY_ROUND (6) if not yet summarized
- At round META_SUMMARY_ROUND (7) for meta-summary - At round META_SUMMARY_ROUND (10) for meta-summary
- Every 5 rounds after that - Every 5 rounds after that
- When estimated token count exceeds MAX_ESTIMATED_TOKENS - When estimated token count exceeds MAX_ESTIMATED_TOKENS
""" """
@ -149,12 +162,23 @@ class ConversationManager:
return True return True
return False return False
async def summarize(self, currentRound: int, aiCallFn) -> Optional[str]: async def summarize(
self,
currentRound: int,
aiCallFn,
externalMemoryKeys: List[str] = None,
) -> Optional[str]:
"""Perform progressive summarization of older messages. """Perform progressive summarization of older messages.
Rounds 1-3: full history retained, no summarization. Rounds 1-5: full history retained, no summarization.
Round 4+: compress older messages into a running summary. Round 6+: compress older messages into a running summary.
Round 7+: meta-summary that consolidates prior summaries. Round 10+: meta-summary that consolidates prior summaries.
Args:
currentRound: Current agent round number.
aiCallFn: Async function that takes a prompt string and returns summary text.
externalMemoryKeys: Keys of RoundMemory entries for this workflow,
so the summary prompt can de-duplicate already-persisted facts.
""" """
if currentRound < FIRST_SUMMARY_ROUND and self.estimateTokenCount() <= MAX_ESTIMATED_TOKENS: if currentRound < FIRST_SUMMARY_ROUND and self.estimateTokenCount() <= MAX_ESTIMATED_TOKENS:
return None return None
@ -184,11 +208,25 @@ class ConversationManager:
messagesToSummarize = nonSystemMessages[:splitIdx] messagesToSummarize = nonSystemMessages[:splitIdx]
recentMessages = nonSystemMessages[splitIdx:] recentMessages = nonSystemMessages[splitIdx:]
# Protect the current user prompt: it must NEVER be summarized away.
promptInRecent = any(m.get("_isCurrentPrompt") for m in recentMessages)
if not promptInRecent:
for i, m in enumerate(messagesToSummarize):
if m.get("_isCurrentPrompt"):
recentMessages = messagesToSummarize[i:] + recentMessages
messagesToSummarize = messagesToSummarize[:i]
break
if not messagesToSummarize:
return None
summaryInput = _formatMessagesForSummary(messagesToSummarize) summaryInput = _formatMessagesForSummary(messagesToSummarize)
previousSummary = self._summaries[-1]["content"] if self._summaries else "" previousSummary = self._summaries[-1]["content"] if self._summaries else ""
isMetaSummary = currentRound >= META_SUMMARY_ROUND and len(self._summaries) >= 2 isMetaSummary = currentRound >= META_SUMMARY_ROUND and len(self._summaries) >= 2
summaryPrompt = _buildSummaryPrompt(summaryInput, previousSummary, isMetaSummary) summaryPrompt = _buildSummaryPrompt(
summaryInput, previousSummary, isMetaSummary,
externalMemoryKeys=externalMemoryKeys,
)
try: try:
summaryText = await aiCallFn(summaryPrompt) summaryText = await aiCallFn(summaryPrompt)
@ -241,8 +279,30 @@ def _formatMessagesForSummary(messages: List[Dict[str, Any]]) -> str:
return "\n\n".join(parts) return "\n\n".join(parts)
def _buildSummaryPrompt(messagesText: str, previousSummary: str, isMetaSummary: bool = False) -> str: def _buildSummaryPrompt(
"""Build the prompt for progressive summarization.""" messagesText: str,
previousSummary: str,
isMetaSummary: bool = False,
externalMemoryKeys: List[str] = None,
) -> str:
"""Build the prompt for progressive summarization.
When externalMemoryKeys is provided, the summary prompt tells the AI
that those facts are preserved in external memory and need not be
repeated verbatim the summary can focus on reasoning, decisions,
and relationships instead.
"""
externalHint = ""
if externalMemoryKeys:
keyList = ", ".join(externalMemoryKeys[:20])
externalHint = (
"NOTE: The following facts are preserved in external persistent memory "
"and do NOT need to be repeated in detail in the summary: "
f"[{keyList}]. "
"Focus on reasoning, decisions, relationships, and anything that is "
"NOT captured by those external memory entries.\n\n"
)
if isMetaSummary: if isMetaSummary:
prompt = ( prompt = (
"Create a comprehensive meta-summary consolidating the previous summary " "Create a comprehensive meta-summary consolidating the previous summary "
@ -251,10 +311,11 @@ def _buildSummaryPrompt(messagesText: str, previousSummary: str, isMetaSummary:
) )
else: else:
prompt = ( prompt = (
"Summarize the following conversation concisely. Preserve all key facts, " "Summarize the following conversation concisely. Preserve key decisions, "
"decisions, entities (names, numbers, dates), and tool results. " "reasoning chains, entities (names, numbers, dates), and action outcomes. "
"Do not lose any important information.\n\n" "Do not lose any important information.\n\n"
) )
prompt += externalHint
if previousSummary: if previousSummary:
prompt += f"Previous Summary:\n{previousSummary}\n\n" prompt += f"Previous Summary:\n{previousSummary}\n\n"
prompt += f"New Messages to Summarize:\n{messagesText}\n\nProvide a concise, factual summary:" prompt += f"New Messages to Summarize:\n{messagesText}\n\nProvide a concise, factual summary:"

View file

@ -142,6 +142,8 @@ class AgentService:
aiCallStreamFn = self._createAiCallStreamFn() aiCallStreamFn = self._createAiCallStreamFn()
getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId) getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId)
buildRagContextFn = self._createBuildRagContextFn() buildRagContextFn = self._createBuildRagContextFn()
persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId)
getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId)
async for event in runAgentLoop( async for event in runAgentLoop(
prompt=enrichedPrompt, prompt=enrichedPrompt,
@ -157,6 +159,8 @@ class AgentService:
aiCallStreamFn=aiCallStreamFn, aiCallStreamFn=aiCallStreamFn,
userLanguage=resolvedLanguage, userLanguage=resolvedLanguage,
conversationHistory=conversationHistory, conversationHistory=conversationHistory,
persistRoundMemoryFn=persistRoundMemoryFn,
getExternalMemoryKeysFn=getExternalMemoryKeysFn,
): ):
if event.type == AgentEventTypeEnum.AGENT_SUMMARY: if event.type == AgentEventTypeEnum.AGENT_SUMMARY:
await self._persistTrace(workflowId, event.data or {}) await self._persistTrace(workflowId, event.data or {})
@ -347,18 +351,120 @@ class AgentService:
) -> str: ) -> str:
try: try:
knowledgeService = self.services.getService("knowledge") knowledgeService = self.services.getService("knowledge")
workflowHintItems = _buildWorkflowHintItems(
self.services, workflowId
)
return await knowledgeService.buildAgentContext( return await knowledgeService.buildAgentContext(
currentPrompt=currentPrompt, currentPrompt=currentPrompt,
workflowId=workflowId, workflowId=workflowId,
userId=userId, userId=userId,
featureInstanceId=featureInstanceId, featureInstanceId=featureInstanceId,
mandateId=mandateId, mandateId=mandateId,
workflowHintItems=workflowHintItems,
) )
except Exception as e: except Exception as e:
logger.debug(f"RAG context not available: {e}") logger.debug(f"RAG context not available: {e}")
return "" return ""
return _buildRagContext return _buildRagContext
def _createPersistRoundMemoryFn(self, workflowId: str):
"""Create callback that persists RoundMemory entries after tool execution."""
from modules.serviceCenter.services.serviceAgent.agentLoop import _classifyToolResult
from modules.datamodels.datamodelKnowledge import RoundMemory
async def _persistRoundMemory(
toolCalls, results, textContent: str, roundNumber: int
):
try:
knowledgeService = self.services.getService("knowledge")
except Exception:
return
knowledgeDb = knowledgeService._knowledgeDb
for tc, result in zip(toolCalls, results):
if not result.success:
continue
classified = _classifyToolResult(tc, result)
if not classified:
continue
summary = classified["summary"]
embedding = await knowledgeService._embedSingle(summary[:500]) if summary else []
mem = RoundMemory(
workflowId=workflowId,
roundNumber=roundNumber,
memoryType=classified["memoryType"],
key=classified["key"],
summary=summary,
fullData=classified.get("fullData"),
fileIds=classified.get("fileIds", []),
embedding=embedding if embedding else None,
)
knowledgeDb.storeRoundMemory(mem)
return _persistRoundMemory
def _createGetExternalMemoryKeysFn(self, workflowId: str):
"""Create callback that returns RoundMemory keys for summarization hints."""
def _getKeys() -> List[str]:
try:
knowledgeService = self.services.getService("knowledge")
memories = knowledgeService._knowledgeDb.getRoundMemories(workflowId)
return [m.get("key", "") for m in memories if m.get("key")]
except Exception:
return []
return _getKeys
def _buildWorkflowHintItems(
services, currentWorkflowId: str
) -> List[Dict[str, Any]]:
"""Build a compact list of other workflows for the RAG cross-workflow hint.
Returns key-value items like:
key="Pendenzenliste Excel (3 msgs)" value="last: 2h ago"
Limited to 10 most recent other workflows to keep the hint small.
"""
try:
chatInterface = services.chat.interfaceDbChat
allWorkflows = chatInterface.getWorkflows() or []
except Exception:
return []
others = [w for w in allWorkflows if w.get("id") != currentWorkflowId]
if not others:
return []
import time as _time
now = _time.time()
others.sort(key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0, reverse=True)
others = others[:10]
items = []
for wf in others:
name = wf.get("name") or "(unnamed)"
createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0
ageSec = now - createdAt if createdAt else 0
if ageSec < 3600:
ageStr = f"{int(ageSec / 60)}m ago"
elif ageSec < 86400:
ageStr = f"{int(ageSec / 3600)}h ago"
else:
ageStr = f"{int(ageSec / 86400)}d ago"
wfId = wf.get("id", "")
items.append({
"key": f"{name} (id: {wfId})",
"value": ageStr,
})
countLabel = f"{len(allWorkflows) - 1} other conversation(s)"
if len(allWorkflows) - 1 > 10:
countLabel += f" (showing 10 newest)"
items.insert(0, {"key": countLabel, "value": "use listWorkflowHistory to browse"})
return items
def _getOrCreateTempFolder(chatService) -> Optional[str]: def _getOrCreateTempFolder(chatService) -> Optional[str]:
"""Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist.""" """Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist."""
@ -2952,3 +3058,138 @@ def _registerCoreTools(registry: ToolRegistry, services):
}, },
readOnly=True readOnly=True
) )
# ---- Cross-workflow tools ----
async def _listWorkflowHistory(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
"""List all chat workflows in this workspace with metadata."""
import json as _json
try:
chatService = services.chat
chatInterface = chatService.interfaceDbChat
allWorkflows = chatInterface.getWorkflows() or []
allWorkflows.sort(
key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0,
reverse=True,
)
allWorkflows = allWorkflows[:50]
items = []
for wf in allWorkflows:
wfId = wf.get("id", "")
name = wf.get("name") or "(unnamed)"
createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0
lastActivity = wf.get("lastActivity") or createdAt
msgs = chatInterface.getMessages(wfId) or []
messageCount = len(msgs)
lastPreview = ""
if msgs:
lastMsg = msgs[-1] if isinstance(msgs[-1], dict) else (
msgs[-1].model_dump() if hasattr(msgs[-1], "model_dump") else {}
)
content = lastMsg.get("message") or lastMsg.get("content") or ""
lastPreview = content[:150]
items.append({
"id": wfId,
"name": name,
"createdAt": createdAt,
"lastActivity": lastActivity,
"messageCount": messageCount,
"lastMessagePreview": lastPreview,
})
return ToolResult(
toolCallId="", toolName="listWorkflowHistory",
success=True, data=_json.dumps(items, ensure_ascii=False),
)
except Exception as e:
return ToolResult(
toolCallId="", toolName="listWorkflowHistory",
success=False, error=str(e),
)
registry.register(
"listWorkflowHistory", _listWorkflowHistory,
description=(
"List all chat conversations/workflows in this workspace. "
"Returns id, name, createdAt, lastActivity, messageCount, and a preview "
"of the last message for each workflow. Use this to discover previous "
"conversations when the user asks about past chats or wants a summary "
"across conversations."
),
parameters={
"type": "object",
"properties": {},
},
readOnly=True,
)
async def _readWorkflowMessages(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
"""Read messages from a specific workflow."""
import json as _json
targetWorkflowId = args.get("workflowId", "")
limit = int(args.get("limit", 20))
offset = int(args.get("offset", 0))
if not targetWorkflowId:
return ToolResult(
toolCallId="", toolName="readWorkflowMessages",
success=False, error="workflowId is required",
)
try:
chatService = services.chat
chatInterface = chatService.interfaceDbChat
allMsgs = chatInterface.getMessages(targetWorkflowId) or []
sliced = allMsgs[offset:offset + limit]
items = []
for msg in sliced:
raw = msg if isinstance(msg, dict) else (
msg.model_dump() if hasattr(msg, "model_dump") else {}
)
content = raw.get("message") or raw.get("content") or ""
if len(content) > 2000:
content = content[:2000] + "..."
items.append({
"role": raw.get("role", ""),
"message": content,
"publishedAt": raw.get("publishedAt") or raw.get("_createdAt") or 0,
})
header = f"Workflow {targetWorkflowId}: {len(allMsgs)} total messages"
if offset > 0 or len(allMsgs) > offset + limit:
header += f" (showing {offset + 1}-{offset + len(sliced)})"
return ToolResult(
toolCallId="", toolName="readWorkflowMessages",
success=True,
data=header + "\n" + _json.dumps(items, ensure_ascii=False),
)
except Exception as e:
return ToolResult(
toolCallId="", toolName="readWorkflowMessages",
success=False, error=str(e),
)
registry.register(
"readWorkflowMessages", _readWorkflowMessages,
description=(
"Read messages from a specific chat workflow/conversation. "
"Use this after listWorkflowHistory to read the content of a "
"specific past conversation. Supports pagination via offset/limit."
),
parameters={
"type": "object",
"properties": {
"workflowId": {"type": "string", "description": "ID of the workflow to read messages from"},
"limit": {"type": "integer", "description": "Max messages to return (default 20)"},
"offset": {"type": "integer", "description": "Skip first N messages (default 0)"},
},
"required": ["workflowId"],
},
readOnly=True,
)

View file

@ -17,6 +17,10 @@ from modules.shared.jsonUtils import (
) )
from .subJsonResponseHandling import JsonResponseHandler from .subJsonResponseHandling import JsonResponseHandler
from modules.datamodels.datamodelAi import JsonAccumulationState from modules.datamodels.datamodelAi import JsonAccumulationState
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted,
)
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
getService as getBillingService, getService as getBillingService,
InsufficientBalanceException, InsufficientBalanceException,
@ -592,10 +596,19 @@ detectedIntent-Werte:
f"Balance {balance_str} CHF, " f"Balance {balance_str} CHF, "
f"Reason: {balanceCheck.reason}" f"Reason: {balanceCheck.reason}"
) )
raise InsufficientBalanceException( if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
currentBalance=balanceCheck.currentBalance or 0.0, ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
requiredAmount=estimatedCost, maybeEmailMandatePoolExhausted(
message=f"Ungenugendes Guthaben. Aktuell: CHF {balance_str}" str(mandateId),
str(user.id),
str(ulabel),
float(balanceCheck.currentBalance or 0.0),
float(estimatedCost),
)
raise InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
str(mandateId),
float(estimatedCost),
) )
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}" balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"

View file

@ -0,0 +1,140 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
When the shared mandate pool (PREPAY_MANDATE) is exhausted, notify billing contacts.
Recipients: BillingSettings.notifyEmails for the mandate (configure as mandate owner / finance).
Emails are throttled per mandate to avoid spam (one notification per cooldown window).
"""
from __future__ import annotations
import html
import logging
import time
from typing import Any, Dict, List, Optional
from modules.datamodels.datamodelMessaging import MessagingChannel
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.security.rootAccess import getRootUser
logger = logging.getLogger(__name__)
# mandate_id -> unix timestamp of last pool-exhausted notification email
_poolExhaustedEmailLastSent: Dict[str, float] = {}
_DEFAULT_COOLDOWN_SEC = 3600
def _normalizeNotifyEmails(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, list):
return [str(e).strip() for e in raw if str(e).strip()]
if isinstance(raw, str):
s = raw.strip()
if not s:
return []
# JSON array string
if s.startswith("["):
try:
import json
parsed = json.loads(s)
if isinstance(parsed, list):
return [str(e).strip() for e in parsed if str(e).strip()]
except Exception:
pass
return [s]
return []
def maybeEmailMandatePoolExhausted(
mandateId: str,
triggeringUserId: str,
triggeringUserLabel: str,
currentBalance: float,
requiredAmount: float,
cooldownSec: float = _DEFAULT_COOLDOWN_SEC,
) -> None:
"""
Send one email per mandate per cooldown to BillingSettings.notifyEmails.
Args:
mandateId: Mandate whose pool is empty.
triggeringUserId: User who hit the block.
triggeringUserLabel: Display (e.g. email or username).
currentBalance: Pool balance (CHF).
requiredAmount: Minimum required (CHF).
cooldownSec: Minimum seconds between emails for this mandate.
"""
if not mandateId:
return
now = time.time()
last = _poolExhaustedEmailLastSent.get(mandateId, 0.0)
if last and (now - last) < cooldownSec:
logger.debug(
"Skip mandate pool exhausted email (cooldown): mandate=%s last=%.0fs ago",
mandateId,
now - last,
)
return
try:
billing = getBillingInterface(getRootUser(), mandateId)
settings = billing.getSettings(mandateId) or {}
recipients = _normalizeNotifyEmails(settings.get("notifyEmails"))
if not recipients:
logger.warning(
"PREPAY_MANDATE pool exhausted for mandate %s but notifyEmails is empty — "
"configure BillingSettings.notifyEmails for owner alerts",
mandateId,
)
return
subject = f"[PowerOn] Mandanten-Budget aufgebraucht (Mandant {mandateId[:8]}…)"
body = (
f"Das gemeinsame Guthaben (PREPAY_MANDATE) für diesen Mandanten ist nicht mehr ausreichend.\n\n"
f"Mandanten-ID: {mandateId}\n"
f"Aktuelles Guthaben (Pool): CHF {currentBalance:.2f}\n"
f"Benötigt (mind.): CHF {requiredAmount:.2f}\n\n"
f"Auslösende/r Benutzer/in: {triggeringUserLabel} (ID: {triggeringUserId})\n\n"
f"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, "
f"damit Benutzer wieder AI-Funktionen nutzen können.\n"
)
escaped = html.escape(body)
# Cannot use '\\n' inside f-string {…} expression (SyntaxError); build replacement outside.
brWithNl = "<br>" + "\n"
htmlMessage = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
{escaped.replace(chr(10), brWithNl)}
</body></html>"""
messaging = getMessagingInterface()
any_ok = False
for to in recipients:
try:
ok = messaging.send(
channel=MessagingChannel.EMAIL,
recipient=to,
subject=subject,
message=htmlMessage,
)
if ok:
any_ok = True
else:
logger.warning("Pool exhausted email failed for %s", to)
except Exception as send_err:
logger.error("Error sending pool exhausted email to %s: %s", to, send_err)
if any_ok:
_poolExhaustedEmailLastSent[mandateId] = now
logger.info(
"Sent mandate pool exhausted notification for mandate %s to %s recipient(s)",
mandateId,
len(recipients),
)
except Exception as e:
logger.error("maybeEmailMandatePoolExhausted failed: %s", e, exc_info=True)

View file

@ -22,6 +22,7 @@ from modules.datamodels.datamodelBilling import (
ReferenceTypeEnum, ReferenceTypeEnum,
BillingTransaction, BillingTransaction,
BillingBalanceResponse, BillingBalanceResponse,
parseBillingModelFromStoredValue,
) )
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@ -333,7 +334,7 @@ class BillingService:
logger.warning(f"No billing settings for mandate {self.mandateId}") logger.warning(f"No billing settings for mandate {self.mandateId}")
return None return None
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get or create account # Get or create account
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
@ -389,15 +390,127 @@ class BillingService:
# Exception Classes # Exception Classes
# ============================================================================ # ============================================================================
BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
def _userActionForBillingModel(bm: BillingModelEnum) -> str:
if bm == BillingModelEnum.PREPAY_USER:
return BILLING_USER_ACTION_TOP_UP_SELF
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
def _buildInsufficientBalanceMessages(
bm: BillingModelEnum,
currentBalance: float,
requiredAmount: float,
) -> tuple:
bal_s = f"{currentBalance:.2f}"
req_s = f"{requiredAmount:.2f}"
if bm == BillingModelEnum.PREPAY_USER:
msg_de = (
f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte laden Sie unter „Billing“ Guthaben nach."
)
msg_en = (
f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please top up under Billing."
)
else:
msg_de = (
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
"Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
)
msg_en = (
f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please contact your mandate administrator. Billing notification contacts were emailed if configured."
)
return msg_de, msg_en
class InsufficientBalanceException(Exception): class InsufficientBalanceException(Exception):
"""Raised when there's insufficient balance for an operation.""" """Raised when there's insufficient balance for an operation.
def __init__(self, currentBalance: float, requiredAmount: float, message: str = None): Carries structured fields for API/SSE clients (userAction, billingModel, localized hints).
self.currentBalance = currentBalance """
self.requiredAmount = requiredAmount
self.message = message or f"Insufficient balance. Current: {currentBalance:.2f} CHF, Required: {requiredAmount:.2f} CHF" def __init__(
self,
currentBalance: float,
requiredAmount: float,
message: Optional[str] = None,
*,
billing_model: Optional[BillingModelEnum] = None,
mandate_id: str = "",
user_action: Optional[str] = None,
message_de: Optional[str] = None,
message_en: Optional[str] = None,
):
self.currentBalance = float(currentBalance)
self.requiredAmount = float(requiredAmount)
self.billing_model = billing_model
self.mandate_id = mandate_id or ""
if billing_model is not None:
self.user_action = user_action or _userActionForBillingModel(billing_model)
else:
self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF
if message_de is not None and message_en is not None:
self.message_de = message_de
self.message_en = message_en
self.message = message or message_de
elif message:
self.message = message
self.message_de = message
self.message_en = message
else:
bm = billing_model or BillingModelEnum.PREPAY_USER
md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
self.message_de = md
self.message_en = me
self.message = md
super().__init__(self.message) super().__init__(self.message)
@classmethod
def fromBalanceCheck(
cls,
check: BillingCheckResult,
mandate_id: str,
required_amount: float,
) -> "InsufficientBalanceException":
bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
bal = float(check.currentBalance or 0.0)
msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount)
return cls(
bal,
required_amount,
message=msg_de,
billing_model=bm,
mandate_id=mandate_id or "",
message_de=msg_de,
message_en=msg_en,
)
def toClientDict(self) -> Dict[str, Any]:
"""Structured payload for HTTP 402, SSE item, or JSON error details."""
out: Dict[str, Any] = {
"error": "INSUFFICIENT_BALANCE",
"currentBalance": round(self.currentBalance, 4),
"requiredAmount": round(self.requiredAmount, 4),
"message": self.message,
"messageDe": self.message_de,
"messageEn": self.message_en,
"userAction": self.user_action,
}
if self.billing_model is not None:
out["billingModel"] = self.billing_model.value
if self.mandate_id:
out["mandateId"] = self.mandate_id
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:
out["billingUiPath"] = "/billing"
return out
class ProviderNotAllowedException(Exception): class ProviderNotAllowedException(Exception):
"""Raised when a user doesn't have permission to use an AI provider.""" """Raised when a user doesn't have permission to use an AI provider."""

View file

@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
CHARS_PER_TOKEN = 4 CHARS_PER_TOKEN = 4
DEFAULT_CHUNK_TOKENS = 400 DEFAULT_CHUNK_TOKENS = 400
DEFAULT_CONTEXT_BUDGET = 8000 DEFAULT_CONTEXT_BUDGET = 12000
class KnowledgeService: class KnowledgeService:
@ -170,8 +170,18 @@ class KnowledgeService:
featureInstanceId: str = "", featureInstanceId: str = "",
mandateId: str = "", mandateId: str = "",
contextBudget: int = DEFAULT_CONTEXT_BUDGET, contextBudget: int = DEFAULT_CONTEXT_BUDGET,
workflowHintItems: List[Dict[str, Any]] = None,
) -> str: ) -> str:
"""Build RAG context for an agent round by searching all 3 layers. """Build RAG context for an agent round by searching all layers.
Layer priority:
0 - File refs from RoundMemory (always included so the agent knows
which files exist in this workflow)
1 - Instance documents (user's own indexed files)
1.5 - Semantically relevant RoundMemory entries
2 - Workflow entities
3 - Shared knowledge
4 - Cross-workflow hint (other conversations in this workspace)
Args: Args:
currentPrompt: The current user prompt to find relevant context for. currentPrompt: The current user prompt to find relevant context for.
@ -180,6 +190,8 @@ class KnowledgeService:
featureInstanceId: Feature instance scope. featureInstanceId: Feature instance scope.
mandateId: Mandate scope. mandateId: Mandate scope.
contextBudget: Maximum characters for the context string. contextBudget: Maximum characters for the context string.
workflowHintItems: Optional pre-built list of other workflow summaries
for the cross-workflow hint layer.
Returns: Returns:
Formatted context string for injection into the agent's system prompt. Formatted context string for injection into the agent's system prompt.
@ -190,6 +202,21 @@ class KnowledgeService:
builder = _ContextBuilder(budget=contextBudget) builder = _ContextBuilder(budget=contextBudget)
# Layer 0: File references from RoundMemory (always included)
fileRefMemories = self._knowledgeDb.getRoundMemoriesByType(workflowId, "file_ref")
if fileRefMemories:
refItems = [
{"key": m.get("key", ""), "value": m.get("summary", "")[:300]}
for m in fileRefMemories
]
builder.add(
priority=0,
label="Known Files",
items=refItems,
isKeyValue=True,
maxChars=2000,
)
# Layer 1: Instance Layer (user's own documents, highest priority) # Layer 1: Instance Layer (user's own documents, highest priority)
instanceChunks = self._knowledgeDb.semanticSearch( instanceChunks = self._knowledgeDb.semanticSearch(
queryVector=queryVector, queryVector=queryVector,
@ -199,12 +226,43 @@ class KnowledgeService:
minScore=0.65, minScore=0.65,
) )
if instanceChunks: if instanceChunks:
builder.add(priority=1, label="Relevant Documents", items=instanceChunks) builder.add(priority=1, label="Relevant Documents", items=instanceChunks, maxChars=4000)
# Layer 1.5: Semantically relevant RoundMemory entries
roundMemories = self._knowledgeDb.semanticSearchRoundMemory(
queryVector=queryVector,
workflowId=workflowId,
limit=10,
minScore=0.55,
)
if roundMemories:
memItems = []
for m in roundMemories:
data = m.get("fullData") or m.get("summary", "")
memItems.append({
"data": data,
"contextRef": {
"type": m.get("memoryType", ""),
"key": m.get("key", ""),
"round": m.get("roundNumber", 0),
},
})
seen = {m.get("key") for m in fileRefMemories} if fileRefMemories else set()
memItems = [
mi for mi in memItems if mi["contextRef"].get("key") not in seen
]
if memItems:
builder.add(
priority=2,
label="Previous Round Context",
items=memItems,
maxChars=4000,
)
# Layer 2: Workflow Layer (current workflow entities & memory) # Layer 2: Workflow Layer (current workflow entities & memory)
entities = self._knowledgeDb.getWorkflowEntities(workflowId) entities = self._knowledgeDb.getWorkflowEntities(workflowId)
if entities: if entities:
builder.add(priority=2, label="Workflow Context", items=entities, isKeyValue=True) builder.add(priority=3, label="Workflow Context", items=entities, isKeyValue=True, maxChars=2000)
# Layer 3: Shared Layer (mandate-wide shared documents) # Layer 3: Shared Layer (mandate-wide shared documents)
sharedChunks = self._knowledgeDb.semanticSearch( sharedChunks = self._knowledgeDb.semanticSearch(
@ -215,7 +273,17 @@ class KnowledgeService:
minScore=0.7, minScore=0.7,
) )
if sharedChunks: if sharedChunks:
builder.add(priority=3, label="Shared Knowledge", items=sharedChunks) builder.add(priority=4, label="Shared Knowledge", items=sharedChunks, maxChars=2000)
# Layer 4: Cross-workflow hint (other conversations in this workspace)
if workflowHintItems:
builder.add(
priority=5,
label="Other Conversations",
items=workflowHintItems,
isKeyValue=True,
maxChars=500,
)
return builder.build() return builder.build()
@ -520,12 +588,14 @@ class _ContextBuilder:
label: str, label: str,
items: List[Dict[str, Any]], items: List[Dict[str, Any]],
isKeyValue: bool = False, isKeyValue: bool = False,
maxChars: int = 0,
): ):
self._sections.append({ self._sections.append({
"priority": priority, "priority": priority,
"label": label, "label": label,
"items": items, "items": items,
"isKeyValue": isKeyValue, "isKeyValue": isKeyValue,
"maxChars": maxChars,
}) })
def build(self) -> str: def build(self) -> str:
@ -537,12 +607,15 @@ class _ContextBuilder:
if remaining <= 0: if remaining <= 0:
break break
sectionCap = section.get("maxChars") or remaining
sectionRemaining = min(sectionCap, remaining)
header = f"### {section['label']}\n" header = f"### {section['label']}\n"
sectionText = header sectionText = header
remaining -= len(header) sectionRemaining -= len(header)
for item in section["items"]: for item in section["items"]:
if remaining <= 0: if sectionRemaining <= 0:
break break
if section["isKeyValue"]: if section["isKeyValue"]:
@ -550,14 +623,15 @@ class _ContextBuilder:
else: else:
data = item.get("data", "") data = item.get("data", "")
ref = item.get("contextRef", {}) ref = item.get("contextRef", {})
score = item.get("_score", "")
refStr = f" [{ref}]" if ref else "" refStr = f" [{ref}]" if ref else ""
line = f"{data}{refStr}\n" line = f"{data}{refStr}\n"
if len(line) <= remaining: if len(line) <= sectionRemaining:
sectionText += line sectionText += line
remaining -= len(line) sectionRemaining -= len(line)
consumed = min(sectionCap, remaining) - sectionRemaining
remaining -= consumed
parts.append(sectionText) parts.append(sectionText)
return "\n".join(parts).strip() return "\n".join(parts).strip()

View file

@ -447,6 +447,11 @@ RESOURCE_OBJECTS = [
"label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"}, "label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"},
"meta": {"category": "store", "featureCode": "workspace"} "meta": {"category": "store", "featureCode": "workspace"}
}, },
{
"objectKey": "resource.store.commcoach",
"label": {"en": "Store: CommCoach", "de": "Store: CommCoach", "fr": "Store: CommCoach"},
"meta": {"category": "store", "featureCode": "commcoach"}
},
{ {
"objectKey": "resource.system.api.auth", "objectKey": "resource.system.api.auth",
"label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"}, "label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"},