commit
d40afae0a9
38 changed files with 3103 additions and 1589 deletions
16
app.py
16
app.py
|
|
@ -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 (
|
||||||
|
|
|
||||||
24
env_dev.env
24
env_dev.env
|
|
@ -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=
|
||||||
|
|
||||||
|
|
|
||||||
24
env_int.env
24
env_int.env
|
|
@ -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=
|
||||||
|
|
||||||
|
|
|
||||||
24
env_prod.env
24
env_prod.env
|
|
@ -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=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
42
modules/auth/oauthProviderConfig.py
Normal file
42
modules/auth/oauthProviderConfig.py
Normal 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)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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}' "
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue