cleanup intra referencings in codebase
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 12s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped

This commit is contained in:
ValueOn AG 2026-06-09 07:05:06 +02:00
parent 4f8473bd70
commit 26dd8f6f3f
78 changed files with 1048 additions and 781 deletions

14
app.py
View file

@ -318,9 +318,23 @@ async def lifespan(app: FastAPI):
onMandateDelete as _waOnMandateDelete,
onInstanceCreate as _waOnInstanceCreate,
)
from modules.interfaces.interfaceDbBilling import (
onMandateDelete as _billingOnMandateDelete,
onMandateProvision as _billingOnMandateProvision,
onStorageChanged as _billingOnStorageChanged,
onUserMandateCreate as _billingOnUserMandateCreate,
onUserMandateDelete as _billingOnUserMandateDelete,
onUserBudgetAdjust as _billingOnUserBudgetAdjust,
)
registerLifecycleHook("onBootstrap", _waOnBootstrap)
registerLifecycleHook("onMandateDelete", _waOnMandateDelete)
registerLifecycleHook("onMandateDelete", _billingOnMandateDelete)
registerLifecycleHook("onMandateProvision", _billingOnMandateProvision)
registerLifecycleHook("onStorageChanged", _billingOnStorageChanged)
registerLifecycleHook("onInstanceCreate", _waOnInstanceCreate)
registerLifecycleHook("onUserMandateCreate", _billingOnUserMandateCreate)
registerLifecycleHook("onUserMandateDelete", _billingOnUserMandateDelete)
registerLifecycleHook("onUserBudgetAdjust", _billingOnUserBudgetAdjust)
# Bootstrap database if needed (creates initial users, mandates, roles, etc.)
# This must happen before getting root interface

View file

@ -10,16 +10,13 @@ import importlib
import os
import time
import threading
from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING
from typing import Dict, List, Optional, Any, Tuple
from modules.datamodels.datamodelAi import AiModel
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelRbac import AccessRuleContext, RbacProtocol
from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
if TYPE_CHECKING:
from modules.security.rbac import RbacClass
logger = logging.getLogger(__name__)
# TODO TESTING: Override maxTokens for all models during testing
@ -188,7 +185,7 @@ class ModelRegistry:
def getAvailableModels(
self,
currentUser: Optional[User] = None,
rbacInstance: Optional["RbacClass"] = None,
rbacInstance: Optional[RbacProtocol] = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> List[AiModel]:
@ -239,7 +236,7 @@ class ModelRegistry:
self,
models: List[AiModel],
currentUser: User,
rbacInstance: "RbacClass",
rbacInstance: RbacProtocol,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> List[AiModel]:
@ -264,7 +261,7 @@ class ModelRegistry:
logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})")
return filteredModels
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional["RbacClass"] = None) -> Optional[AiModel]:
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacProtocol] = None) -> Optional[AiModel]:
"""Get a specific model by displayName, optionally checking RBAC permissions.
Args:

View file

@ -10,7 +10,7 @@ Multi-Tenant Design:
"""
import uuid
from typing import Optional
from typing import Optional, Dict, List, Protocol, runtime_checkable
from enum import Enum
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
@ -174,6 +174,20 @@ class AccessRule(PowerOnModel):
)
@runtime_checkable
class RbacProtocol(Protocol):
"""Structural type for RBAC checkers — allows aicore (L3) to reference
the RBAC contract without importing from security (L4)."""
def checkResourceAccessBulk(
self,
user: "User",
resourcePaths: List[str],
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> Dict[str, bool]: ...
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
IMMUTABLE_FIELDS = {
"Role": ["mandateId", "featureInstanceId", "featureCode"],

View file

@ -169,7 +169,7 @@ class InvestorDemo2026(BaseDemoConfig):
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
if existing:

View file

@ -154,7 +154,7 @@ class PwgDemo2026(BaseDemoConfig):
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
if existing:

View file

@ -597,8 +597,8 @@ def _createCommcoachRagFn(
from modules.serviceCenter.context import ServiceCenterContext
serviceContext = ServiceCenterContext(
user=currentUser,
mandate_id=mandateId,
feature_instance_id=featureInstanceId,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
)
knowledgeService = getService("knowledge", serviceContext)
ragContext = await knowledgeService.buildAgentContext(
@ -902,8 +902,8 @@ class CommcoachService:
serviceContext = ServiceCenterContext(
user=self.currentUser,
mandate_id=self.mandateId,
feature_instance_id=self.instanceId,
mandateId=self.mandateId,
featureInstanceId=self.instanceId,
)
agentService = getService("agent", serviceContext)
@ -1240,8 +1240,8 @@ class CommcoachService:
serviceContext = ServiceCenterContext(
user=self.currentUser,
mandate_id=self.mandateId,
feature_instance_id=self.instanceId,
mandateId=self.mandateId,
featureInstanceId=self.instanceId,
)
knowledgeService = getService("knowledge", serviceContext)
parsedGoals = aiPrompts._parseJsonField(context.get("goals") if context else None, [])
@ -1535,8 +1535,8 @@ class CommcoachService:
serviceContext = ServiceCenterContext(
user=self.currentUser,
mandate_id=self.mandateId,
feature_instance_id=self.instanceId,
mandateId=self.mandateId,
featureInstanceId=self.instanceId,
)
aiService = getService("ai", serviceContext)
await aiService.ensureAiObjectsInitialized()
@ -1561,8 +1561,8 @@ class CommcoachService:
serviceContext = ServiceCenterContext(
user=self.currentUser,
mandate_id=self.mandateId,
feature_instance_id=self.instanceId,
mandateId=self.mandateId,
featureInstanceId=self.instanceId,
)
aiService = getService("ai", serviceContext)
await aiService.ensureAiObjectsInitialized()

View file

@ -309,8 +309,8 @@ class InterfaceFeatureNeutralizer:
) -> Optional[DataNeutralizerAttributes]:
"""Create a neutralization attribute for placeholder resolution."""
try:
mandate_id = self.mandateId or ""
feature_instance_id = self.featureInstanceId or ""
mandateId = self.mandateId or ""
featureInstanceId = self.featureInstanceId or ""
if not self.userId:
logger.warning("Cannot create attribute: missing userId")
return None

View file

@ -22,7 +22,7 @@ class NeutralizationPlayground:
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self._ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId)
self._ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
def _getService(self, name: str):
return getService(name, self._ctx)
@ -258,7 +258,7 @@ class SharepointProcessor:
self._sharepoint = getService("sharepoint", ctx)
self._neutralization = getService("neutralization", ctx)
from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface
self._interfaceDbApp = _getAppInterface(currentUser, mandateId=ctx.mandate_id)
self._interfaceDbApp = _getAppInterface(currentUser, mandateId=ctx.mandateId)
async def processSharepointFiles(self, sourcePath: str, targetPath: str) -> Dict[str, Any]:
try:

View file

@ -58,18 +58,17 @@ def get_neutralization_config(
) -> DataNeutraliserConfig:
"""Get data neutralization configuration"""
try:
mandate_id = str(context.mandateId) if context.mandateId else ""
feature_instance_id = str(context.featureInstanceId) if context.featureInstanceId else ""
mandateId = str(context.mandateId) if context.mandateId else ""
featureInstanceId = str(context.featureInstanceId) if context.featureInstanceId else ""
service = NeutralizationPlayground(
context.user, mandate_id, featureInstanceId=feature_instance_id or None
context.user, mandateId, featureInstanceId=featureInstanceId or None
)
config = service.getConfig()
if not config:
# Return default config instead of 404 (requires mandateId and featureInstanceId for instance-scoped config)
return DataNeutraliserConfig(
mandateId=mandate_id,
featureInstanceId=feature_instance_id,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
userId=context.user.id,
enabled=True,
namesToParse="",

View file

@ -64,8 +64,8 @@ class NeutralizationService:
elif serviceCenter and getattr(serviceCenter, "user", None):
self.interfaceNeutralizer = getNeutralizerInterface(
currentUser=serviceCenter.user,
mandateId=getattr(serviceCenter, 'mandateId', None) or getattr(serviceCenter, 'mandate_id', None),
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(serviceCenter, 'feature_instance_id', None),
mandateId=getattr(serviceCenter, 'mandateId', None),
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None),
)
namesList = NamesToParse if isinstance(NamesToParse, list) else []

View file

@ -232,7 +232,7 @@ async def processNaturalLanguageCommand(
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})")
logger.debug(f"User input: {userInput}")
ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId)
ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId)
aiService = getService("ai", ctx)
intentAnalysis = await analyzeUserIntent(aiService, userInput)

View file

@ -234,7 +234,7 @@ async def extract_bzo_information(
bzo_params_result = None
try:
ctx = ServiceCenterContext(user=currentUser, mandate_id=_mandateId, feature_instance_id=featureInstanceId)
ctx = ServiceCenterContext(user=currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId)
ai_service = getService("ai", ctx)
bzo_params_result = await run_bzo_params_extraction(
extracted_content=all_extracted_content,
@ -520,7 +520,7 @@ async def generate_bauzone_ai_summary(
AI-generated summary string
"""
try:
ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId)
ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
aiService = getService("ai", ctx)
context_parts = []

View file

@ -13,7 +13,7 @@ from __future__ import annotations
import logging
import time
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Tuple
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.connectors.connectorTicketsRedmine import ConnectorTicketsRedmine
@ -21,6 +21,9 @@ from modules.datamodels.datamodelUam import User
from modules.features.redmine.datamodelRedmine import (
RedmineConfigDto,
RedmineConfigUpdateRequest,
RedmineCustomFieldSchemaDto,
RedmineFieldChoiceDto,
RedmineFieldSchemaDto,
RedmineInstanceConfig,
RedmineRelationMirror,
RedmineTicketMirror,
@ -447,3 +450,135 @@ def getInterface(
featureInstanceId=effectiveFeatureInstanceId,
)
return _redmineInterfaces[contextKey]
# ---------------------------------------------------------------------------
# Project meta -- with TTL cache stored on the config record
# ---------------------------------------------------------------------------
class RedmineNotConfiguredError(RuntimeError):
"""The given feature instance has no usable Redmine config."""
def _resolveRootTrackerId(
rootTrackerName: str, trackers: List[Dict[str, Any]]
) -> Optional[int]:
"""Resolve the configured root tracker name to a tracker id.
Strict: case-insensitive exact match. Returns ``None`` if not found
(the UI must surface this as a config error).
"""
target = (rootTrackerName or "").strip().lower()
if not target:
return None
for t in trackers:
if str(t.get("name") or "").strip().lower() == target:
tid = t.get("id")
return int(tid) if tid is not None else None
return None
def _schemaFromCache(
projectId: str, cache: Optional[Dict[str, Any]], rootTrackerName: str
) -> Optional[RedmineFieldSchemaDto]:
if not cache:
return None
trackers = cache.get("trackers") or []
return RedmineFieldSchemaDto(
projectId=projectId,
projectName=str(cache.get("projectName") or ""),
trackers=[RedmineFieldChoiceDto(**t) for t in trackers],
statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []],
priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []],
users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []],
categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []],
customFields=[
RedmineCustomFieldSchemaDto(
id=cf.get("id"),
name=cf.get("name", ""),
fieldFormat=cf.get("fieldFormat", "string"),
isRequired=bool(cf.get("isRequired")),
possibleValues=list(cf.get("possibleValues") or []),
multiple=bool(cf.get("multiple")),
defaultValue=cf.get("defaultValue"),
)
for cf in cache.get("customFields") or []
if cf.get("id") is not None
],
rootTrackerName=rootTrackerName,
rootTrackerId=_resolveRootTrackerId(rootTrackerName, trackers),
)
async def getProjectMeta(
currentUser: User,
mandateId: Optional[str],
featureInstanceId: str,
*,
forceRefresh: bool = False,
) -> RedmineFieldSchemaDto:
"""Fetch (or return cached) project metadata: trackers, statuses, priorities, etc."""
iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
connector = iface.resolveConnector(featureInstanceId)
if not connector:
raise RedmineNotConfiguredError(
f"Redmine instance {featureInstanceId} is not configured or inactive"
)
cfg = iface.getConfig(featureInstanceId)
if cfg is None:
raise RedmineNotConfiguredError("Config row vanished after connector resolve")
ttl = cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60
fresh_enough = (
cfg.schemaCache
and cfg.schemaCachedAt
and (time.time() - cfg.schemaCachedAt) < ttl
)
if fresh_enough and not forceRefresh:
schema = _schemaFromCache(cfg.projectId, cfg.schemaCache, cfg.rootTrackerName)
if schema is not None:
return schema
project_info = await connector.getProjectInfo()
trackers_raw = await connector.getTrackers()
statuses_raw = await connector.getStatuses()
priorities_raw = await connector.getPriorities()
custom_fields_raw = await connector.getCustomFields()
users_raw = await connector.getProjectUsers()
categories_raw = await connector.getIssueCategories()
schema_cache: Dict[str, Any] = {
"projectName": project_info.get("name", ""),
"trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers_raw],
"statuses": [
{
"id": s.get("id"),
"name": s.get("name"),
"isClosed": bool(s.get("is_closed")),
}
for s in statuses_raw
],
"priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw],
"users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw],
"categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None],
"customFields": [
{
"id": cf.get("id"),
"name": cf.get("name"),
"fieldFormat": cf.get("field_format", "string"),
"isRequired": bool(cf.get("is_required")),
"possibleValues": [pv.get("value") for pv in (cf.get("possible_values") or []) if pv.get("value") is not None],
"multiple": bool(cf.get("multiple")),
"defaultValue": cf.get("default_value"),
}
for cf in custom_fields_raw
],
}
iface.updateSchemaCache(featureInstanceId, schema_cache)
iface.markConfigConnected(featureInstanceId)
return _schemaFromCache(cfg.projectId, schema_cache, cfg.rootTrackerName) or RedmineFieldSchemaDto(
projectId=cfg.projectId,
projectName=schema_cache["projectName"],
rootTrackerName=cfg.rootTrackerName,
)

View file

@ -32,7 +32,7 @@ from modules.features.redmine.datamodelRedmine import (
RedmineTicketDto,
RedmineTicketUpdateRequest,
)
from modules.features.redmine.serviceRedmine import RedmineNotConfiguredError
from modules.features.redmine.interfaceFeatureRedmine import RedmineNotConfiguredError
from modules.connectors.connectorTicketsRedmine import RedmineApiError
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface

View file

@ -25,7 +25,6 @@ workflow engine without context-magic.
from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
@ -35,9 +34,7 @@ from modules.connectors.connectorTicketsRedmine import (
)
from modules.datamodels.datamodelUam import User
from modules.features.redmine.datamodelRedmine import (
RedmineCustomFieldSchemaDto,
RedmineCustomFieldValueDto,
RedmineFieldChoiceDto,
RedmineFieldSchemaDto,
RedmineRelationCreateRequest,
RedmineRelationDto,
@ -46,8 +43,10 @@ from modules.features.redmine.datamodelRedmine import (
RedmineTicketUpdateRequest,
)
from modules.features.redmine.interfaceFeatureRedmine import (
RedmineNotConfiguredError,
RedmineObjects,
getInterface,
getProjectMeta,
)
from modules.features.redmine.serviceRedmineStatsCache import getStatsCache
@ -58,9 +57,6 @@ logger = logging.getLogger(__name__)
# Resolution helpers
# ---------------------------------------------------------------------------
class RedmineNotConfiguredError(RuntimeError):
"""The given feature instance has no usable Redmine config."""
def _resolveContext(
currentUser: User, mandateId: Optional[str], featureInstanceId: str
@ -74,127 +70,6 @@ def _resolveContext(
return iface, connector
# ---------------------------------------------------------------------------
# Project meta -- with TTL cache stored on the config record
# ---------------------------------------------------------------------------
async def getProjectMeta(
currentUser: User,
mandateId: Optional[str],
featureInstanceId: str,
*,
forceRefresh: bool = False,
) -> RedmineFieldSchemaDto:
iface, connector = _resolveContext(currentUser, mandateId, featureInstanceId)
cfg = iface.getConfig(featureInstanceId)
if cfg is None:
raise RedmineNotConfiguredError("Config row vanished after connector resolve")
ttl = cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60
fresh_enough = (
cfg.schemaCache
and cfg.schemaCachedAt
and (time.time() - cfg.schemaCachedAt) < ttl
)
if fresh_enough and not forceRefresh:
schema = _schemaFromCache(cfg.projectId, cfg.schemaCache, cfg.rootTrackerName)
if schema is not None:
return schema
project_info = await connector.getProjectInfo()
trackers_raw = await connector.getTrackers()
statuses_raw = await connector.getStatuses()
priorities_raw = await connector.getPriorities()
custom_fields_raw = await connector.getCustomFields()
users_raw = await connector.getProjectUsers()
categories_raw = await connector.getIssueCategories()
schema_cache: Dict[str, Any] = {
"projectName": project_info.get("name", ""),
"trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers_raw],
"statuses": [
{
"id": s.get("id"),
"name": s.get("name"),
"isClosed": bool(s.get("is_closed")),
}
for s in statuses_raw
],
"priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw],
"users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw],
"categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None],
"customFields": [
{
"id": cf.get("id"),
"name": cf.get("name"),
"fieldFormat": cf.get("field_format", "string"),
"isRequired": bool(cf.get("is_required")),
"possibleValues": [pv.get("value") for pv in (cf.get("possible_values") or []) if pv.get("value") is not None],
"multiple": bool(cf.get("multiple")),
"defaultValue": cf.get("default_value"),
}
for cf in custom_fields_raw
],
}
iface.updateSchemaCache(featureInstanceId, schema_cache)
iface.markConfigConnected(featureInstanceId)
return _schemaFromCache(cfg.projectId, schema_cache, cfg.rootTrackerName) or RedmineFieldSchemaDto(
projectId=cfg.projectId,
projectName=schema_cache["projectName"],
rootTrackerName=cfg.rootTrackerName,
)
def _resolveRootTrackerId(
rootTrackerName: str, trackers: List[Dict[str, Any]]
) -> Optional[int]:
"""Resolve the configured root tracker name to a tracker id.
Strict: case-insensitive exact match. Returns ``None`` if not found
(the UI must surface this as a config error).
"""
target = (rootTrackerName or "").strip().lower()
if not target:
return None
for t in trackers:
if str(t.get("name") or "").strip().lower() == target:
tid = t.get("id")
return int(tid) if tid is not None else None
return None
def _schemaFromCache(
projectId: str, cache: Optional[Dict[str, Any]], rootTrackerName: str
) -> Optional[RedmineFieldSchemaDto]:
if not cache:
return None
trackers = cache.get("trackers") or []
return RedmineFieldSchemaDto(
projectId=projectId,
projectName=str(cache.get("projectName") or ""),
trackers=[RedmineFieldChoiceDto(**t) for t in trackers],
statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []],
priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []],
users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []],
categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []],
customFields=[
RedmineCustomFieldSchemaDto(
id=cf.get("id"),
name=cf.get("name", ""),
fieldFormat=cf.get("fieldFormat", "string"),
isRequired=bool(cf.get("isRequired")),
possibleValues=list(cf.get("possibleValues") or []),
multiple=bool(cf.get("multiple")),
defaultValue=cf.get("defaultValue"),
)
for cf in cache.get("customFields") or []
if cf.get("id") is not None
],
rootTrackerName=rootTrackerName,
rootTrackerId=_resolveRootTrackerId(rootTrackerName, trackers),
)
# ---------------------------------------------------------------------------
# Mirror -> RedmineTicketDto

View file

@ -83,10 +83,8 @@ async def getStats(
# Lazy import: keeps the pure aggregation helpers below importable
# without dragging in aiohttp / DB connector at module load.
from modules.features.redmine.serviceRedmine import (
getProjectMeta,
listTickets,
)
from modules.features.redmine.interfaceFeatureRedmine import getProjectMeta
from modules.features.redmine.serviceRedmine import listTickets
schema = await getProjectMeta(currentUser, mandateId, featureInstanceId)
root_tracker_id = schema.rootTrackerId

View file

@ -38,7 +38,7 @@ from modules.features.redmine.datamodelRedmine import (
RedmineSyncStatusDto,
RedmineTicketMirror,
)
from modules.features.redmine.interfaceFeatureRedmine import getInterface
from modules.features.redmine.interfaceFeatureRedmine import getInterface, getProjectMeta
from modules.features.redmine.serviceRedmineStatsCache import getStatsCache
logger = logging.getLogger(__name__)
@ -281,8 +281,6 @@ async def _ensureSchemaWarm(
statuses = (cfg.schemaCache or {}).get("statuses") or []
if statuses:
return
# Lazy import to avoid a circular dependency at module load.
from modules.features.redmine.serviceRedmine import getProjectMeta
try:
await getProjectMeta(currentUser, mandateId, featureInstanceId, forceRefresh=True)
except Exception as e:

View file

@ -405,9 +405,9 @@ def createAiService(user, mandateId, featureInstanceId=None):
"""Create a properly wired AiService via the service center."""
ctx = ServiceCenterContext(
user=user,
mandate_id=mandateId,
feature_instance_id=featureInstanceId,
feature_code="teamsbot",
mandateId=mandateId,
featureInstanceId=featureInstanceId,
featureCode="teamsbot",
)
return _getServiceCenterService("ai", ctx)
@ -1320,9 +1320,9 @@ class TeamsbotService:
ctx = ServiceCenterContext(
user=self.currentUser,
mandate_id=self.mandateId,
feature_instance_id=self.instanceId,
feature_code="teamsbot",
mandateId=self.mandateId,
featureInstanceId=self.instanceId,
featureCode="teamsbot",
)
agentService = _getServiceCenterService("agent", ctx)

View file

@ -247,8 +247,8 @@ async def _cmdSendMail(service, sessionId: str, params: dict):
from modules.serviceCenter import ServiceCenterContext, getService
ctx = ServiceCenterContext(
user=service.currentUser,
mandate_id=service.mandateId,
feature_instance_id=service.instanceId,
mandateId=service.mandateId,
featureInstanceId=service.instanceId,
)
messaging = getService("messaging", ctx)
success = messaging.sendEmailDirect(
@ -280,8 +280,8 @@ async def _cmdStoreDocument(service, sessionId: str, params: dict):
from modules.serviceCenter import ServiceCenterContext, getService
ctx = ServiceCenterContext(
user=service.currentUser,
mandate_id=service.mandateId,
feature_instance_id=service.instanceId,
mandateId=service.mandateId,
featureInstanceId=service.instanceId,
)
sharepoint = getService("sharepoint", ctx)
if not sharepoint.setAccessTokenFromConnection(service.currentUser):

View file

@ -566,10 +566,10 @@ async def streamWorkspaceStart(
wsBillingFeatureCode = _workspaceBillingFeatureCode(context.user, mandateId or "", instanceId)
svcCtx = ServiceCenterContext(
user=context.user,
mandate_id=mandateId or "",
feature_instance_id=instanceId,
workflow_id=workflowId,
feature_code=wsBillingFeatureCode,
mandateId=mandateId or "",
featureInstanceId=instanceId,
workflowId=workflowId,
featureCode=wsBillingFeatureCode,
)
chatSvc = getService("chat", svcCtx)
attachmentLabel = _buildWorkspaceAttachmentLabel(
@ -687,10 +687,10 @@ async def _runWorkspaceAgent(
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=user,
mandate_id=mandateId,
feature_instance_id=instanceId,
workflow_id=workflowId,
feature_code=billingFeatureCode,
mandateId=mandateId,
featureInstanceId=instanceId,
workflowId=workflowId,
featureCode=billingFeatureCode,
)
agentService = getService("agent", ctx)
chatService = getService("chat", ctx)
@ -1299,7 +1299,7 @@ async def listWorkspaceDataSources(
try:
from modules.datamodels.datamodelDataSource import DataSource
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByConnection
from modules.serviceCenter.core.flagResolution import buildEffectiveByConnection
rootIf = getRootInterface()
recordFilter: dict = {"featureInstanceId": instanceId}
if wsMandateId:
@ -1352,8 +1352,8 @@ async def createWorkspaceDataSource(
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
mandate_id=_mandateId or "",
feature_instance_id=instanceId,
mandateId=_mandateId or "",
featureInstanceId=instanceId,
)
chatService = getService("chat", ctx)
dataSource = chatService.createDataSource(
@ -1381,8 +1381,8 @@ async def deleteWorkspaceDataSource(
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
mandate_id=_mandateId or "",
feature_instance_id=instanceId,
mandateId=_mandateId or "",
featureInstanceId=instanceId,
)
chatService = getService("chat", ctx)
chatService.deleteDataSource(dataSourceId)
@ -1464,7 +1464,7 @@ async def listFeatureDataSources(
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
from modules.serviceCenter.core.flagResolution import buildEffectiveByWorkspaceFds
rootIf = getRootInterface()
recordFilter: dict = {}

View file

@ -11,7 +11,6 @@ Multi-Tenant Design:
"""
import logging
import uuid
from typing import Optional, Dict
from passlib.context import CryptContext
from modules.connectors.connectorDbPostgre import DatabaseConnector
@ -521,6 +520,8 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
Ensure all existing mandates have system-instance roles.
Serves as both initial setup and migration for existing mandates.
"""
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
allMandates = db.getRecordset(Mandate)
if not allMandates:
logger.info("No mandates found, skipping system role copy")
@ -534,94 +535,6 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}")
def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
"""
Copy system template roles (mandateId=None, isSystemRole=True) to a mandate
as mandate-instance roles. Also copies all AccessRules for each role.
This is analogous to how feature template roles are copied to feature instances.
Each mandate gets its own instances of admin/user/viewer with their AccessRules.
Args:
db: Database connector instance
mandateId: Target mandate ID
Returns:
Number of roles copied
"""
# Find system template roles (global: mandateId=NULL, isSystemRole=True)
templateRoles = db.getRecordset(
Role,
recordFilter={"isSystemRole": True, "mandateId": None}
)
if not templateRoles:
logger.warning(f"No system template roles found (mandateId IS NULL, isSystemRole=True)")
return 0
# Check which mandate-level roles already exist for this mandate
existingMandateRoles = db.getRecordset(
Role,
recordFilter={"mandateId": mandateId, "featureInstanceId": None}
)
existingLabels = {r.get("roleLabel") for r in existingMandateRoles}
logger.info(f"copySystemRolesToMandate: mandate={mandateId}, templates={len(templateRoles)}, existing={len(existingMandateRoles)}, labels={existingLabels}")
# Load all AccessRules for template roles
templateRoleIds = [r.get("id") for r in templateRoles]
rulesByRoleId = {}
for roleId in templateRoleIds:
rules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
rulesByRoleId[roleId] = rules
copiedCount = 0
for templateRole in templateRoles:
roleLabel = templateRole.get("roleLabel")
# Skip if mandate already has this role
if roleLabel in existingLabels:
logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping")
continue
newRoleId = str(uuid.uuid4())
# Create mandate-instance role
newRole = Role(
id=newRoleId,
roleLabel=roleLabel,
description=coerce_text_multilingual(templateRole.get("description", {})),
mandateId=mandateId,
featureInstanceId=None,
featureCode=None,
isSystemRole=False # Mandate-level role, not a system template
)
db.recordCreate(Role, newRole.model_dump())
# Copy AccessRules
templateRules = rulesByRoleId.get(templateRole.get("id"), [])
for rule in templateRules:
newRule = AccessRule(
id=str(uuid.uuid4()),
roleId=newRoleId,
context=rule.get("context"),
item=rule.get("item"),
view=rule.get("view", False),
read=rule.get("read"),
create=rule.get("create"),
update=rule.get("update"),
delete=rule.get("delete")
)
db.recordCreate(AccessRule, newRule.model_dump())
copiedCount += 1
logger.info(f"Copied system role '{roleLabel}' to mandate {mandateId} with {len(templateRules)} AccessRules")
if copiedCount > 0:
logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}")
return copiedCount
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
"""
Get role ID by label, using cache or database lookup.

View file

@ -1560,7 +1560,7 @@ class AppObjects:
# Copy system template roles to new mandate (admin, user, viewer + AccessRules)
try:
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
copiedCount = copySystemRolesToMandate(self.db, mandateId)
logger.info(f"Copied {copiedCount} system roles to new mandate {mandateId}")
except Exception as e:
@ -1577,7 +1577,7 @@ class AppObjects:
"""
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.shared.featureDiscovery import loadFeatureMainModules
plan = BUILTIN_PLANS.get(planKey)
@ -1615,7 +1615,7 @@ class AppObjects:
raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role")
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
from modules.shared.systemComponentRegistry import getLifecycleHooks as _getHooks
now = datetime.now(timezone.utc)
nowTs = now.timestamp()
@ -1635,17 +1635,11 @@ class AppObjects:
subInterface = _getSubRoot()
subInterface.createSubscription(subscription)
try:
billingRoot = _getBillingRoot()
billingRoot.getOrCreateSettings(mandateId)
billingRoot.ensureActivationBudget(mandateId, planKey)
except Exception as billingEx:
logger.error(
"Initial billing setup failed for mandate %s (plan=%s): %s",
mandateId,
planKey,
billingEx,
)
for _hook in _getHooks("onMandateProvision"):
try:
_hook(mandateId, planKey)
except Exception as _hookErr:
logger.error("onMandateProvision hook failed: %s", _hookErr)
self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True)
@ -1865,7 +1859,6 @@ class AppObjects:
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.datamodels.datamodelBilling import BillingSettings, BillingAccount, BillingTransaction
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
@ -1987,20 +1980,7 @@ class AppObjects:
subInterface.db.recordDelete(MandateSubscription, subId)
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
# 3b. Delete Billing data (poweron_billing)
from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
billingDb = _getBillingRoot().db
billingAccounts = billingDb.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
for acc in billingAccounts:
accTxs = billingDb.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")})
for tx in accTxs:
billingDb.recordDelete(BillingTransaction, tx.get("id"))
billingDb.recordDelete(BillingAccount, acc.get("id"))
billingSettings = billingDb.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId})
for bs in billingSettings:
billingDb.recordDelete(BillingSettings, bs.get("id"))
if billingAccounts or billingSettings:
logger.info(f"Cascade: deleted billing data for mandate {mandateId}")
# 3b. Billing data cascade handled by onMandateDelete lifecycle hook (interfaceDbBilling)
# 3c. Delete Invitations for this mandate
from modules.datamodels.datamodelInvitation import Invitation
@ -2155,10 +2135,20 @@ class AppObjects:
)
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
self._ensureUserBillingAccount(userId, mandateId)
from modules.shared.systemComponentRegistry import getLifecycleHooks
for _hook in getLifecycleHooks("onUserMandateCreate"):
try:
_hook(userId, mandateId)
except Exception as _hookErr:
logger.warning("onUserMandateCreate hook failed: %s", _hookErr)
self._syncSubscriptionQuantity(mandateId)
if not skipCapacityCheck:
self._adjustAiBudgetForUserChange(mandateId, delta=+1)
for _hook in getLifecycleHooks("onUserBudgetAdjust"):
try:
_hook(mandateId, +1)
except Exception as _hookErr:
logger.warning("onUserBudgetAdjust hook failed: %s", _hookErr)
cleanedRecord = dict(createdRecord)
return UserMandate(**cleanedRecord)
@ -2167,26 +2157,6 @@ class AppObjects:
raise
logger.error(f"Error creating UserMandate: {e}")
raise ValueError(f"Failed to create UserMandate: {e}") from e
def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None:
"""
Ensure a user has a billing audit account for the mandate.
Balance is always on the mandate pool (PREPAY_MANDATE). User accounts are for audit trail only.
"""
try:
from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRootInterface
billingInterface = getBillingRootInterface()
settings = billingInterface.getSettings(mandateId)
if not settings:
return
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
logger.info(f"Ensured billing audit account for user {userId} in mandate {mandateId}")
except Exception as e:
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
def _checkSubscriptionCapacity(self, mandateId: str, resourceType: str, delta: int = 1) -> None:
"""Check subscription capacity before creating a resource. Raises on cap violation."""
@ -2222,23 +2192,6 @@ class AppObjects:
raise
logger.debug(f"Subscription quantity sync skipped: {e}")
def _adjustAiBudgetForUserChange(self, mandateId: str, delta: int) -> None:
"""Pro-rata AI budget credit/debit when a user is added or removed mid-cycle."""
try:
from modules.interfaces.interfaceDbSubscription import getInterface as getSubInterface
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
from modules.security.rootAccess import getRootUser
rootUser = getRootUser()
subIf = getSubInterface(rootUser, mandateId)
operative = subIf.getOperativeForMandate(mandateId)
if not operative:
return
planKey = operative.get("planKey", "")
billingIf = getBillingInterface(rootUser)
billingIf.adjustAiBudgetForUserChange(mandateId, planKey, delta)
except Exception as e:
logger.debug(f"AI budget adjustment skipped: {e}")
def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
"""
Delete a UserMandate record (remove user from mandate).
@ -2278,7 +2231,14 @@ class AppObjects:
result = self.db.recordDelete(UserMandate, existing.id)
self._syncSubscriptionQuantity(mandateId)
self._adjustAiBudgetForUserChange(mandateId, delta=-1)
from modules.shared.systemComponentRegistry import getLifecycleHooks
for _hook in getLifecycleHooks("onUserMandateDelete"):
try:
_hook(userId, mandateId)
except Exception as _hookErr:
logger.warning("onUserMandateDelete hook failed: %s", _hookErr)
return result
except Exception as e:
logger.error(f"Error deleting UserMandate: {e}")

View file

@ -2144,3 +2144,83 @@ class BillingObjects:
# Sort by creation date descending and limit
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getUserTransactionsForMandates")
return allTransactions[:limit]
def deleteMandateData(self, mandateId: str) -> None:
"""Delete all billing data for a mandate (accounts, transactions, settings).
Used as cascade during mandate hard-delete via the onMandateDelete lifecycle hook.
"""
billingAccounts = self.db.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
for acc in billingAccounts:
accTxs = self.db.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")})
for tx in accTxs:
self.db.recordDelete(BillingTransaction, tx.get("id"))
self.db.recordDelete(BillingAccount, acc.get("id"))
billingSettings = self.db.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId})
for bs in billingSettings:
self.db.recordDelete(BillingSettings, bs.get("id"))
if billingAccounts or billingSettings:
logger.info("deleteMandateData: deleted billing data for mandate %s", mandateId)
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Lifecycle hook: cascade-delete billing data when a mandate is hard-deleted."""
getRootInterface().deleteMandateData(mandateId)
def onUserMandateCreate(userId: str, mandateId: str) -> None:
"""Lifecycle hook: ensure user has a billing audit account when added to a mandate."""
try:
billingInterface = getRootInterface()
settings = billingInterface.getSettings(mandateId)
if not settings:
return
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
logger.info("Ensured billing audit account for user %s in mandate %s", userId, mandateId)
except Exception as e:
logger.warning("Failed to create billing account for user %s (non-critical): %s", userId, e)
def onUserMandateDelete(userId: str, mandateId: str) -> None:
"""Lifecycle hook: pro-rata AI budget debit when user is removed from a mandate."""
_adjustAiBudgetForUserChange(mandateId, delta=-1)
def onUserBudgetAdjust(mandateId: str, delta: int) -> None:
"""Lifecycle hook: pro-rata AI budget credit/debit for user membership changes."""
_adjustAiBudgetForUserChange(mandateId, delta)
def onMandateProvision(mandateId: str, planKey: str) -> None:
"""Lifecycle hook: create billing settings and activation budget for a new mandate."""
try:
billingRoot = getRootInterface()
billingRoot.getOrCreateSettings(mandateId)
billingRoot.ensureActivationBudget(mandateId, planKey)
except Exception as e:
logger.error("Initial billing setup failed for mandate %s (plan=%s): %s", mandateId, planKey, e)
def onStorageChanged(mandateId: str) -> None:
"""Lifecycle hook: reconcile storage billing after knowledge content changes."""
try:
getRootInterface().reconcileMandateStorageBilling(mandateId)
except Exception as e:
logger.warning("reconcileMandateStorageBilling failed for mandate %s: %s", mandateId, e)
def _adjustAiBudgetForUserChange(mandateId: str, delta: int) -> None:
"""Pro-rata AI budget credit/debit when a user is added or removed mid-cycle."""
try:
from modules.interfaces.interfaceDbSubscription import getInterface as getSubInterface
from modules.security.rootAccess import getRootUser
rootUser = getRootUser()
subIf = getSubInterface(rootUser, mandateId)
operative = subIf.getOperativeForMandate(mandateId)
if not operative:
return
planKey = operative.get("planKey", "")
billingIf = getInterface(rootUser)
billingIf.adjustAiBudgetForUserChange(mandateId, planKey, delta)
except Exception as e:
logger.debug("AI budget adjustment skipped: %s", e)

View file

@ -123,13 +123,13 @@ class KnowledgeObjects:
if mid:
mandateIds.add(str(mid))
from modules.shared.systemComponentRegistry import getLifecycleHooks
for mid in mandateIds:
try:
from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot
getBillingRoot().reconcileMandateStorageBilling(mid)
except Exception as ex:
logger.warning("reconcileMandateStorageBilling after connection purge failed: %s", ex)
for _hook in getLifecycleHooks("onStorageChanged"):
try:
_hook(mid)
except Exception as ex:
logger.warning("onStorageChanged hook after connection purge failed: %s", ex)
return {"indexRows": indexCount, "chunks": chunkCount}
@ -166,12 +166,13 @@ class KnowledgeObjects:
if mid:
mandateIds.add(str(mid))
from modules.shared.systemComponentRegistry import getLifecycleHooks
for mid in mandateIds:
try:
from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot
getBillingRoot().reconcileMandateStorageBilling(mid)
except Exception as ex:
logger.warning("reconcileMandateStorageBilling after datasource purge failed: %s", ex)
for _hook in getLifecycleHooks("onStorageChanged"):
try:
_hook(mid)
except Exception as ex:
logger.warning("onStorageChanged hook after datasource purge failed: %s", ex)
return {"indexRows": indexCount, "chunks": chunkCount}
@ -196,12 +197,12 @@ class KnowledgeObjects:
self.db.recordDelete(ContentChunk, chunk["id"])
ok = self.db.recordDelete(FileContentIndex, fileId)
if ok and mandateId:
try:
from modules.interfaces.interfaceDbBilling import getRootInterface
getRootInterface().reconcileMandateStorageBilling(str(mandateId))
except Exception as ex:
logger.warning("reconcileMandateStorageBilling after delete failed: %s", ex)
from modules.shared.systemComponentRegistry import getLifecycleHooks
for _hook in getLifecycleHooks("onStorageChanged"):
try:
_hook(str(mandateId))
except Exception as ex:
logger.warning("onStorageChanged hook after delete failed: %s", ex)
return ok
# =========================================================================

View file

@ -27,12 +27,15 @@ import json
import math
import re
import copy
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Type, Union
from pydantic import BaseModel
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.security.rbac import RbacClass
from modules.security.rootAccess import getRootDbAppConnector
@ -1123,3 +1126,96 @@ def _checkRowPermission(
# Unknown level - deny by default
return False
# =============================================================================
# System Role Provisioning
# =============================================================================
def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
"""
Copy system template roles (mandateId=None, isSystemRole=True) to a mandate
as mandate-instance roles. Also copies all AccessRules for each role.
This is analogous to how feature template roles are copied to feature instances.
Each mandate gets its own instances of admin/user/viewer with their AccessRules.
Args:
db: Database connector instance
mandateId: Target mandate ID
Returns:
Number of roles copied
"""
templateRoles = db.getRecordset(
Role,
recordFilter={"isSystemRole": True, "mandateId": None}
)
if not templateRoles:
logger.warning("No system template roles found (mandateId IS NULL, isSystemRole=True)")
return 0
existingMandateRoles = db.getRecordset(
Role,
recordFilter={"mandateId": mandateId, "featureInstanceId": None}
)
existingLabels = {r.get("roleLabel") for r in existingMandateRoles}
logger.info(
"copySystemRolesToMandate: mandate=%s, templates=%s, existing=%s, labels=%s",
mandateId, len(templateRoles), len(existingMandateRoles), existingLabels,
)
templateRoleIds = [r.get("id") for r in templateRoles]
rulesByRoleId = {}
for roleId in templateRoleIds:
rules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
rulesByRoleId[roleId] = rules
copiedCount = 0
for templateRole in templateRoles:
roleLabel = templateRole.get("roleLabel")
if roleLabel in existingLabels:
logger.debug("Mandate %s already has role '%s', skipping", mandateId, roleLabel)
continue
newRoleId = str(uuid.uuid4())
newRole = Role(
id=newRoleId,
roleLabel=roleLabel,
description=coerce_text_multilingual(templateRole.get("description", {})),
mandateId=mandateId,
featureInstanceId=None,
featureCode=None,
isSystemRole=False,
)
db.recordCreate(Role, newRole.model_dump())
templateRules = rulesByRoleId.get(templateRole.get("id"), [])
for rule in templateRules:
newRule = AccessRule(
id=str(uuid.uuid4()),
roleId=newRoleId,
context=rule.get("context"),
item=rule.get("item"),
view=rule.get("view", False),
read=rule.get("read"),
create=rule.get("create"),
update=rule.get("update"),
delete=rule.get("delete"),
)
db.recordCreate(AccessRule, newRule.model_dump())
copiedCount += 1
logger.info(
"Copied system role '%s' to mandate %s with %s AccessRules",
roleLabel, mandateId, len(templateRules),
)
if copiedCount > 0:
logger.info("Copied %s system roles to mandate %s", copiedCount, mandateId)
return copiedCount

View file

@ -798,7 +798,7 @@ async def _updateKnowledgeConsent(
cancelled = cancelJobsByConnection(connectionId)
else:
from modules.datamodels.datamodelDataSource import DataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
from modules.serviceCenter.core.flagResolution import getEffectiveFlag
allConnDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
dataSources = [
ds for ds in (allConnDs or [])

View file

@ -98,17 +98,17 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man
return
file_meta = mgmtInterface.getFile(fileId)
feature_instance_id = ""
mandate_id = ""
featureInstanceId = ""
mandateId = ""
file_scope = "personal"
if file_meta:
if isinstance(file_meta, dict):
feature_instance_id = file_meta.get("featureInstanceId") or ""
mandate_id = file_meta.get("mandateId") or ""
featureInstanceId = file_meta.get("featureInstanceId") or ""
mandateId = file_meta.get("mandateId") or ""
file_scope = file_meta.get("scope") or "personal"
else:
feature_instance_id = getattr(file_meta, "featureInstanceId", None) or ""
mandate_id = getattr(file_meta, "mandateId", None) or ""
featureInstanceId = getattr(file_meta, "featureInstanceId", None) or ""
mandateId = getattr(file_meta, "mandateId", None) or ""
file_scope = getattr(file_meta, "scope", None) or "personal"
logger.info(f"Auto-index starting for {fileName} ({len(rawBytes)} bytes, {mimeType})")
@ -121,8 +121,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man
fileId=fileId,
fileName=fileName,
userId=userId,
featureInstanceId=str(feature_instance_id) if feature_instance_id else "",
mandateId=str(mandate_id) if mandate_id else "",
featureInstanceId=str(featureInstanceId) if featureInstanceId else "",
mandateId=str(mandateId) if mandateId else "",
scope=file_scope,
)
logger.info(
@ -208,8 +208,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man
ctx = ServiceCenterContext(
user=user,
mandate_id=str(mandate_id) if mandate_id else "",
feature_instance_id=str(feature_instance_id) if feature_instance_id else "",
mandateId=str(mandateId) if mandateId else "",
featureInstanceId=str(featureInstanceId) if featureInstanceId else "",
)
knowledgeService = getService("knowledge", ctx)
@ -222,8 +222,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man
fileName=fileName,
mimeType=mimeType,
userId=userId,
featureInstanceId=str(feature_instance_id) if feature_instance_id else "",
mandateId=str(mandate_id) if mandate_id else "",
featureInstanceId=str(featureInstanceId) if featureInstanceId else "",
mandateId=str(mandateId) if mandateId else "",
contentObjects=contentObjects,
structure=contentIndex.structure,
provenance={"lane": "upload", "route": "routeDataFiles._autoIndexFile"},

View file

@ -86,7 +86,7 @@ def _buildConnectionInventory(connections, rootIf, knowledgeIf, jobService) -> L
"""
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelKnowledge import FileContentIndex
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
from modules.serviceCenter.core.flagResolution import getEffectiveFlag
out = []
for conn in connections:
@ -236,7 +236,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
from modules.datamodels.datamodelKnowledge import FileContentIndex
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds
from modules.serviceCenter.services.serviceBackgroundJobs import mainBackgroundJobService as jobService
from modules.serviceCenter.services.serviceKnowledge.subFeatureBootstrap import FEATURE_BOOTSTRAP_JOB_TYPE
@ -548,7 +548,7 @@ async def _reindexConnection(
if str(conn.userId) != str(currentUser.id):
raise HTTPException(status_code=403, detail="Not your connection")
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
from modules.serviceCenter.core.flagResolution import getEffectiveFlag
dataSources = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
ragDs = [ds for ds in dataSources if getEffectiveFlag(ds, "ragIndexEnabled", dataSources, mode="walk") is True]
if not ragDs:

View file

@ -251,7 +251,7 @@ async def _generateTtsSampleTextForLocale(
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException
mandateId = _resolveMandateIdForVoiceTestAi(request, currentUser)
ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=None)
ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=None)
aiService = getService("ai", ctx)
systemPrompt = (

View file

@ -596,8 +596,8 @@ def _buildServiceCenterContext(context: RequestContext, mandateId: str, instance
from modules.serviceCenter.context import ServiceCenterContext
return ServiceCenterContext(
user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else mandateId,
feature_instance_id=instanceId,
mandateId=str(context.mandateId) if context.mandateId else mandateId,
featureInstanceId=instanceId,
)
@ -1366,6 +1366,21 @@ def _buildExecuteRunEnvelope(
return env
def _startEmailPollerIfNeeded(result: dict) -> None:
"""Start the background email poller when a run pauses for email wait."""
if not isinstance(result, dict) or result.get("waitReason") != "email":
return
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.workflowAutomation.scheduler.emailPoller import ensureRunning
root = getRootInterface()
eventUser = root.getUserByUsername("event") if root else None
if eventUser:
ensureRunning(eventUser)
except Exception as pollErr:
logger.warning("Could not start email poller: %s", pollErr)
@router.post("/workflows/{workflowId}/execute")
@limiter.limit("30/minute")
async def _executeWorkflow(
@ -1446,6 +1461,7 @@ async def _executeWorkflow(
"workflowAutomation execute result: success=%s error=%s paused=%s",
result.get("success"), result.get("error"), result.get("paused"),
)
_startEmailPollerIfNeeded(result)
return result
@ -1778,7 +1794,7 @@ async def _completeTask(
graph = wfForGraph["graph"]
services = _getWorkflowAutomationServices(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return await executeGraph(
result = await executeGraph(
graph=graph,
services=services,
workflowId=workflowId,
@ -1790,6 +1806,8 @@ async def _completeTask(
startAfterNodeId=taskNodeId,
runId=runId,
)
_startEmailPollerIfNeeded(result)
return result
@router.post("/tasks/{taskId}/cancel")

View file

@ -33,7 +33,7 @@ def getService(
Args:
key: Service key (e.g., "web", "extraction", "utils")
context: ServiceCenterContext with user, mandate_id, feature_instance_id, workflow
context: ServiceCenterContext with user, mandateId, featureInstanceId, workflow
Returns:
Service instance

View file

@ -16,20 +16,10 @@ class ServiceCenterContext:
"""Context for service resolution: user, mandate, feature instance, optional workflow."""
user: User
mandate_id: Optional[str] = None
feature_instance_id: Optional[str] = None
workflow_id: Optional[str] = None
mandateId: Optional[str] = None
featureInstanceId: Optional[str] = None
workflowId: Optional[str] = None
workflow: Any = None
requireNeutralization: Optional[bool] = None
# When workflow is absent (e.g. workspace agent), billing/UI still need feature code for transactions.
feature_code: Optional[str] = None
@property
def mandateId(self) -> Optional[str]:
"""Alias for mandate_id (backward compatibility)."""
return self.mandate_id
@property
def featureInstanceId(self) -> Optional[str]:
"""Alias for feature_instance_id (backward compatibility)."""
return self.feature_instance_id
featureCode: Optional[str] = None

View file

@ -20,12 +20,12 @@ class SecurityService:
def __init__(self, context: Any, get_service: Callable[[str], Any]):
"""Initialize with service center context and resolver."""
self._context = context
self._get_service = get_service
self._getService = get_service
self._tokenManager = TokenManager()
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
self._interfaceDbApp = getAppInterface(
context.user,
mandateId=context.mandate_id,
mandateId=context.mandateId,
)
def getFreshToken(self, connectionId: str, secondsBeforeExpiry: int = 30 * 60) -> Optional[Token]:

View file

@ -19,7 +19,7 @@ class StreamingService:
def __init__(self, context: Any, get_service: Callable[[str], Any]):
"""Initialize with service center context and resolver."""
self._context = context
self._get_service = get_service
self._getService = get_service
def getEventManager(self) -> EventManager:
"""Get the global event manager instance for SSE streaming."""

View file

@ -22,7 +22,7 @@ class UtilsService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with service center context and resolver."""
self._context = context
self._get_service = get_service
self._getService = get_service
# ===== Event handling =====

View file

@ -0,0 +1,90 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Neutral protocol types used across serviceCenter services.
Protocols defined here break import cycles by providing structural typing
contracts that services can depend on without importing concrete classes
from sibling services.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
# ---------------------------------------------------------------------------
# FeatureDataProviderProtocol (used by serviceKnowledge, implemented in serviceAgent)
# ---------------------------------------------------------------------------
@runtime_checkable
class FeatureDataProviderProtocol(Protocol):
"""Structural contract for the RBAC-scoped feature-data read layer.
serviceKnowledge depends on this Protocol for RAG indexing;
serviceAgent supplies the concrete FeatureDataProvider implementation.
"""
def browseTable(
self,
tableName: str,
featureInstanceId: str,
mandateId: str,
fields: Optional[List[str]] = None,
limit: int = 50,
offset: int = 0,
extraFilters: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]: ...
async def finalizeRowsAsync(
self,
tableName: str,
rows: List[Dict[str, Any]],
) -> List[Dict[str, Any]]: ...
# ---------------------------------------------------------------------------
# FeatureDataProvider factory registry
# ---------------------------------------------------------------------------
_featureDataProviderFactory = None
def registerFeatureDataProviderFactory(factory) -> None:
"""Register the concrete FeatureDataProvider class (called at composition time)."""
global _featureDataProviderFactory
_featureDataProviderFactory = factory
def createFeatureDataProvider(
dbConnector,
neutralizeFields: Optional[Dict[str, List[str]]] = None,
neutralizePolicy: Optional[Dict[str, Dict[str, Any]]] = None,
neutralizationService: Optional[Any] = None,
) -> FeatureDataProviderProtocol:
"""Instantiate a FeatureDataProvider without importing serviceAgent."""
if _featureDataProviderFactory is None:
raise RuntimeError(
"FeatureDataProvider factory not registered. "
"Ensure serviceAgent is initialized before serviceKnowledge bootstrap runs."
)
return _featureDataProviderFactory(
dbConnector,
neutralizeFields=neutralizeFields,
neutralizePolicy=neutralizePolicy,
neutralizationService=neutralizationService,
)
# ---------------------------------------------------------------------------
# RendererProtocol (used by serviceExtraction, implemented in serviceGeneration)
# ---------------------------------------------------------------------------
@runtime_checkable
class RendererProtocol(Protocol):
"""Structural contract for document renderers.
serviceExtraction depends on this Protocol for type hints;
serviceGeneration supplies BaseRenderer and its subclasses.
"""
def getExtractionGuidelines(self) -> str: ...

View file

@ -19,7 +19,7 @@ GetServiceFunc = Callable[[str], Any]
def _make_context_id(ctx: ServiceCenterContext) -> str:
"""Create a stable cache key from context."""
return f"{id(ctx.user)}_{ctx.mandate_id or ''}_{ctx.feature_instance_id or ''}"
return f"{id(ctx.user)}_{ctx.mandateId or ''}_{ctx.featureInstanceId or ''}"
def _load_service_class(module_path: str, class_name: str):

View file

@ -1,3 +1,8 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""serviceAgent: AI Agent with ReAct loop and native function calling."""
from modules.serviceCenter.core.types import registerFeatureDataProviderFactory
from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
registerFeatureDataProviderFactory(FeatureDataProvider)

View file

@ -151,7 +151,7 @@ def registerDataSourceTools(registry: ToolRegistry, services):
sourceType = ds.get("sourceType", "")
path = ds.get("path", "/")
label = ds.get("label", "")
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
from modules.serviceCenter.core.flagResolution import getEffectiveFlag
from modules.datamodels.datamodelDataSource import DataSource
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()

View file

@ -109,7 +109,7 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services):
recordFilter={"featureInstanceId": featureInstanceId},
)
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds
_fdsAll = featureDataSources or []
_anySourceNeutralize = any(
getEffectiveFlagFds(ds, "neutralize", _fdsAll, mode="walk") is True
@ -160,7 +160,7 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services):
# A2: build the per-table type/inheritance-aware neutralization policy.
# tableActive = effective (own or inherited) table-level neutralize flag;
# explicitFields = fields whose neutralize flag is set explicitly.
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import resolveEffectiveForFds
from modules.serviceCenter.core.flagResolution import resolveEffectiveForFds
neutralizePolicy: Dict[str, Dict[str, Any]] = {}
for tblObj in selectedTables:
tn = tblObj.get("meta", {}).get("table", "") if isinstance(tblObj, dict) else ""

View file

@ -68,8 +68,8 @@ class ServicesBag:
self._context = context
self._getService = getService
self.user = context.user
self.mandateId = context.mandate_id
self.featureInstanceId = context.feature_instance_id
self.mandateId = context.mandateId
self.featureInstanceId = context.featureInstanceId
@property
def workflow(self):

View file

@ -42,10 +42,10 @@ class _ServicesAdapter:
Workflow is read from context dynamically so propagation updates are visible."""
def __init__(self, context, get_service: Callable[[str], Any]):
self._context = context
self._get_service = get_service
self._getService = get_service
self.user = context.user
self.mandateId = context.mandate_id
self.featureInstanceId = context.feature_instance_id
self.mandateId = context.mandateId
self.featureInstanceId = context.featureInstanceId
@property
def workflow(self):
@ -57,31 +57,31 @@ class _ServicesAdapter:
@property
def chat(self):
return self._get_service("chat")
return self._getService("chat")
@property
def extraction(self):
return self._get_service("extraction")
return self._getService("extraction")
@property
def utils(self):
return self._get_service("utils")
return self._getService("utils")
@property
def ai(self):
return self._get_service("ai")
return self._getService("ai")
@property
def interfaceDbChat(self):
return self._get_service("chat").interfaceDbChat
return self._getService("chat").interfaceDbChat
@property
def interfaceDbComponent(self):
return self._get_service("chat").interfaceDbComponent
return self._getService("chat").interfaceDbComponent
@property
def featureCode(self) -> Optional[str]:
fc = getattr(self._context, "feature_code", None)
fc = getattr(self._context, "featureCode", None)
if fc and str(fc).strip():
return str(fc).strip()
w = self.workflow
@ -102,11 +102,11 @@ class AiService:
"""Initialize with ServiceCenterContext and service resolver.
Args:
context: ServiceCenterContext with user, mandate_id, feature_instance_id, workflow
context: ServiceCenterContext with user, mandateId, featureInstanceId, workflow
get_service: Callable to resolve dependency services by key
"""
self.services = _ServicesAdapter(context, get_service)
self._get_service = get_service
self._getService = get_service
self.aiObjects = None
self.extractionService = None
@ -117,7 +117,7 @@ class AiService:
if self.extractionService is None:
logger.info("Initializing ExtractionService via service center...")
self.extractionService = self._get_service("extraction")
self.extractionService = self._getService("extraction")
# Initialize new submodules
from .subResponseParsing import ResponseParser
@ -673,7 +673,7 @@ detectedIntent-Werte:
_sources = []
# Source 1: Feature-Instance config
_neutralSvc = self._get_service("neutralization")
_neutralSvc = self._getService("neutralization")
if _neutralSvc and hasattr(_neutralSvc, 'getConfig'):
_config = _neutralSvc.getConfig()
if _config and getattr(_config, 'enabled', False):
@ -721,7 +721,7 @@ detectedIntent-Werte:
_hardMode = request.requireNeutralization is True
excludedDocs: List[str] = []
neutralSvc = self._get_service("neutralization")
neutralSvc = self._getService("neutralization")
if not neutralSvc or not hasattr(neutralSvc, 'processTextAsync'):
if _hardMode:
raise RuntimeError("Neutralization explicitly required but service unavailable — AI call BLOCKED")
@ -1193,7 +1193,7 @@ detectedIntent-Werte:
contentOut = getattr(response, 'content', None)
contentOutput = str(contentOut) if contentOut else None
neutralSvc = self._get_service("neutralization") if wasNeutralized else None
neutralSvc = self._getService("neutralization") if wasNeutralized else None
mappingsCount = None
if neutralSvc and hasattr(neutralSvc, 'getActiveMappingsCount'):
try:
@ -1324,8 +1324,8 @@ detectedIntent-Werte:
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=servicesHub.user,
mandate_id=servicesHub.mandateId,
feature_instance_id=servicesHub.featureInstanceId,
mandateId=servicesHub.mandateId,
featureInstanceId=servicesHub.featureInstanceId,
workflow=getattr(servicesHub, "workflow", None),
)
return getService("ai", ctx)
@ -1721,7 +1721,7 @@ Respond with ONLY a JSON object in this exact format:
)
try:
generationService = self._get_service("generation")
generationService = self._getService("generation")
# renderReport verarbeitet jetzt jedes Dokument einzeln
# und gibt Liste von (documentData, mimeType, filename) zurück

View file

@ -56,9 +56,9 @@ def getService(currentUser: User, mandateId: str, featureInstanceId: str = None,
return _billingServices[cacheKey]
def _get_feature_code_from_context(context) -> Optional[str]:
def _getFeatureCodeFromContext(context) -> Optional[str]:
"""Extract featureCode from ServiceCenterContext."""
explicit = getattr(context, "feature_code", None)
explicit = getattr(context, "featureCode", None)
if explicit and str(explicit).strip():
return str(explicit).strip()
if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature:
@ -91,15 +91,15 @@ class BillingService:
ctx = context_or_user
get_service = mandateId
self.currentUser = ctx.user
self.mandateId = ctx.mandate_id or ""
self.featureInstanceId = ctx.feature_instance_id
self.featureCode = _get_feature_code_from_context(ctx)
self.mandateId = ctx.mandateId or ""
self.featureInstanceId = ctx.featureInstanceId
self.featureCode = _getFeatureCodeFromContext(ctx)
elif get_service is not None and hasattr(context_or_user, "user"):
ctx = context_or_user
self.currentUser = ctx.user
self.mandateId = ctx.mandate_id or ""
self.featureInstanceId = ctx.feature_instance_id
self.featureCode = _get_feature_code_from_context(ctx)
self.mandateId = ctx.mandateId or ""
self.featureInstanceId = ctx.featureInstanceId
self.featureCode = _getFeatureCodeFromContext(ctx)
else:
self.currentUser = context_or_user
self.mandateId = mandateId or ""

View file

@ -18,17 +18,17 @@ class ChatService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with ServiceCenterContext and service resolver."""
self._context = context
self._get_service = get_service
self._getService = get_service
self.user = context.user
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id)
self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id, featureInstanceId=context.feature_instance_id)
self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandateId)
self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandateId, featureInstanceId=context.featureInstanceId)
self.interfaceDbChat = getChatInterface(
context.user,
mandateId=context.mandate_id,
featureInstanceId=context.feature_instance_id,
mandateId=context.mandateId,
featureInstanceId=context.featureInstanceId,
)
self._progressLogger = None
@ -374,10 +374,10 @@ class ChatService:
try:
# Get a fresh token via security service
logger.debug(f"Getting fresh token for connection {connection.id}")
token = self._get_service("security").getFreshToken(connection.id)
token = self._getService("security").getFreshToken(connection.id)
if token:
if hasattr(token, 'expiresAt') and token.expiresAt:
current_time = self._get_service("utils").timestampGetUtc()
current_time = self._getService("utils").timestampGetUtc()
if current_time > token.expiresAt:
token_status = "expired"
else:
@ -462,7 +462,7 @@ class ChatService:
Token object or None if not found/expired
"""
try:
return self._get_service("security").getFreshToken(connectionId)
return self._getService("security").getFreshToken(connectionId)
except Exception as e:
logger.error(f"Error getting fresh token for connection {connectionId}: {str(e)}")
return None
@ -575,8 +575,8 @@ class ChatService:
path=path,
label=label,
displayPath=displayPath,
featureInstanceId=featureInstanceId or self._context.feature_instance_id or "",
mandateId=self._context.mandate_id or "",
featureInstanceId=featureInstanceId or self._context.featureInstanceId or "",
mandateId=self._context.mandateId or "",
userId=self.user.id if self.user else "",
)
return self.interfaceDbApp.db.recordCreate(DataSource, ds)

View file

@ -30,7 +30,7 @@ class ClickupService(ClickupApiClient):
def __init__(self, context, get_service: Callable[[str], Any]):
super().__init__(accessToken="")
self._context = context
self._get_service = get_service
self._getService = get_service
def setAccessTokenFromConnection(self, userConnection) -> bool:
"""Load OAuth/personal token from SecurityService for this UserConnection."""
@ -45,7 +45,7 @@ class ClickupService(ClickupApiClient):
if not connection_id:
logger.error("UserConnection must have an 'id' field")
return False
security = self._get_service("security")
security = self._getService("security")
if not security:
logger.error("Security service not available for token access")
return False

View file

@ -28,12 +28,12 @@ class ExtractionService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with ServiceCenterContext and service resolver."""
self._context = context
self._get_service = get_service
self._getService = get_service
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
self._interfaceDbComponent = getComponentInterface(
context.user,
mandateId=context.mandate_id,
featureInstanceId=context.feature_instance_id,
mandateId=context.mandateId,
featureInstanceId=context.featureInstanceId,
)
self._extractorRegistry = getExtractorRegistry()
if ExtractionService._sharedChunkerRegistry is None:
@ -117,7 +117,7 @@ class ExtractionService:
docOperationId = f"{operationId}_doc_{i}"
# Use parentOperationId if provided, otherwise use operationId as parent
parentId = parentOperationId if parentOperationId else operationId
self._get_service("chat").progressLogStart(
self._getService("chat").progressLogStart(
docOperationId,
"Extracting Document",
f"Document {i + 1}/{totalDocs}",
@ -130,17 +130,17 @@ class ExtractionService:
try:
if docOperationId:
self._get_service("chat").progressLogUpdate(docOperationId, 0.1, "Loading document data")
self._getService("chat").progressLogUpdate(docOperationId, 0.1, "Loading document data")
# Resolve raw bytes for this document using interface
documentBytes = dbInterface.getFileData(doc.fileId)
if not documentBytes:
if docOperationId:
self._get_service("chat").progressLogFinish(docOperationId, False)
self._getService("chat").progressLogFinish(docOperationId, False)
raise ValueError(f"No file data found for fileId={doc.fileId}")
if docOperationId:
self._get_service("chat").progressLogUpdate(docOperationId, 0.2, "Running extraction pipeline")
self._getService("chat").progressLogUpdate(docOperationId, 0.2, "Running extraction pipeline")
# Convert ChatDocument to the format expected by runExtraction
documentData = {
@ -160,7 +160,7 @@ class ExtractionService:
)
if docOperationId:
self._get_service("chat").progressLogUpdate(docOperationId, 0.7, f"Extracted {len(ec.parts)} parts")
self._getService("chat").progressLogUpdate(docOperationId, 0.7, f"Extracted {len(ec.parts)} parts")
# Log content parts metadata
logger.debug(f"Content parts: {len(ec.parts)}")
@ -223,7 +223,7 @@ class ExtractionService:
# Use document name and part index for filename
doc_name_safe = documentData["fileName"].replace(" ", "_").replace("/", "_").replace("\\", "_")[:50]
debug_filename = f"extraction_text_part_{j+1}_{doc_name_safe}.txt"
self._get_service("utils").writeDebugFile(debug_json, debug_filename)
self._getService("utils").writeDebugFile(debug_json, debug_filename)
logger.info(f"Wrote debug file for extracted text part {j+1}/{len(ec.parts)}: {debug_filename}")
except Exception as e:
logger.warning(f"Failed to write debug file for text part {j+1}: {str(e)}")
@ -240,7 +240,7 @@ class ExtractionService:
logger.debug(f"No chunking needed - {len(ec.parts)} parts fit within size limits")
if docOperationId:
self._get_service("chat").progressLogUpdate(docOperationId, 0.9, f"Processing complete: {len(ec.parts)} parts extracted")
self._getService("chat").progressLogUpdate(docOperationId, 0.9, f"Processing complete: {len(ec.parts)} parts extracted")
# Calculate timing and emit stats
endTime = time.time()
@ -256,7 +256,7 @@ class ExtractionService:
# Hard fail if model is missing; caller must ensure connectors are registered
if model is None or model.calculatepriceCHF is None:
if docOperationId:
self._get_service("chat").progressLogFinish(docOperationId, False)
self._getService("chat").progressLogFinish(docOperationId, False)
raise RuntimeError(f"Pricing model not available: {modelDisplayName}")
priceCHF = model.calculatepriceCHF(processingTime, bytesSent, bytesReceived)
@ -309,13 +309,13 @@ class ExtractionService:
# Finish document operation successfully
if docOperationId:
self._get_service("chat").progressLogFinish(docOperationId, True)
self._getService("chat").progressLogFinish(docOperationId, True)
except Exception as e:
logger.error(f"Error extracting content from document {i + 1}/{totalDocs} ({doc.fileName}): {str(e)}")
if docOperationId:
try:
self._get_service("chat").progressLogFinish(docOperationId, False)
self._getService("chat").progressLogFinish(docOperationId, False)
except:
pass # Don't fail on progress logging errors
# Continue with next document instead of failing completely
@ -355,7 +355,7 @@ class ExtractionService:
if not operationId:
workflowId = self._context.workflow.id if self._context.workflow else f"no-workflow-{int(time.time())}"
operationId = f"ai_text_extract_{workflowId}_{int(time.time())}"
self._get_service("chat").progressLogStart(
self._getService("chat").progressLogStart(
operationId,
"AI Text Extract",
"Document Processing",
@ -383,19 +383,19 @@ class ExtractionService:
# Extract content WITHOUT chunking
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.1, f"Extracting content from {len(documents)} documents")
self._getService("chat").progressLogUpdate(operationId, 0.1, f"Extracting content from {len(documents)} documents")
# Pass operationId as parentOperationId for hierarchical logging
# Correct hierarchy: parentOperationId -> operationId -> docOperationId
extractionResult = self.extractContent(documents, extractionOptions, operationId=operationId, parentOperationId=operationId)
if not isinstance(extractionResult, list):
if operationId:
self._get_service("chat").progressLogFinish(operationId, False)
self._getService("chat").progressLogFinish(operationId, False)
return "[Error: No extraction results]"
# Process parts (not chunks) with model-aware AI calls
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.3, f"Processing {len(extractionResult)} extracted content parts")
self._getService("chat").progressLogUpdate(operationId, 0.3, f"Processing {len(extractionResult)} extracted content parts")
# Use operationId as parentOperationId for child operations
# Correct hierarchy: parentOperationId -> operationId -> partOperationId
processParentOperationId = operationId
@ -403,20 +403,20 @@ class ExtractionService:
# Merge results using existing merging system
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.9, f"Merging {len(partResults)} part results")
self._getService("chat").progressLogUpdate(operationId, 0.9, f"Merging {len(partResults)} part results")
mergedContent = self.mergePartResults(partResults, options)
# Save merged extraction content to debug
self._get_service("utils").writeDebugFile(mergedContent or '', "extraction_merged_text")
self._getService("utils").writeDebugFile(mergedContent or '', "extraction_merged_text")
if operationId:
self._get_service("chat").progressLogFinish(operationId, True)
self._getService("chat").progressLogFinish(operationId, True)
return mergedContent
except Exception as e:
logger.error(f"Error in processDocumentsPerChunk: {str(e)}")
if operationId:
self._get_service("chat").progressLogFinish(operationId, False)
self._getService("chat").progressLogFinish(operationId, False)
raise
async def _processPartsWithMapping(
@ -468,7 +468,7 @@ class ExtractionService:
if operationId:
workflowId = self._context.workflow.id if self._context.workflow else f"no-workflow-{int(time.time())}"
partOperationId = f"{operationId}_part_{part_index}"
self._get_service("chat").progressLogStart(
self._getService("chat").progressLogStart(
partOperationId,
"Content Processing",
f"Part {part_index + 1}",
@ -487,15 +487,15 @@ class ExtractionService:
# Update progress - initiating
if partOperationId:
self._get_service("chat").progressLogUpdate(partOperationId, 0.3, "Initiating")
self._getService("chat").progressLogUpdate(partOperationId, 0.3, "Initiating")
# Call AI with model-aware chunking (no progress callback - handled by parent operation)
response = await aiObjects.call(request)
# Update progress - completed
if partOperationId:
self._get_service("chat").progressLogUpdate(partOperationId, 0.9, "Completed")
self._get_service("chat").progressLogFinish(partOperationId, True)
self._getService("chat").progressLogUpdate(partOperationId, 0.9, "Completed")
self._getService("chat").progressLogFinish(partOperationId, True)
processing_time = time.time() - start_time
@ -1133,7 +1133,7 @@ class ExtractionService:
"perPartExtractedData": per_part_extracted_data
}
debug_json = json.dumps(debug_content, indent=2, ensure_ascii=False)
self._get_service("utils").writeDebugFile(debug_json, "content_extraction_per_part")
self._getService("utils").writeDebugFile(debug_json, "content_extraction_per_part")
logger.info(f"Wrote per-part extracted data to debug file: {len(per_part_extracted_data)} blocks from {len(content_parts)} content parts")
except Exception as e:
logger.warning(f"Failed to write per-part extracted data to debug file: {str(e)}")
@ -1172,7 +1172,7 @@ class ExtractionService:
extraction_result_format["parts"].append(formatted_part)
result_json = json.dumps(extraction_result_format, indent=2, ensure_ascii=False)
self._get_service("utils").writeDebugFile(result_json, "content_extraction_original_parts")
self._getService("utils").writeDebugFile(result_json, "content_extraction_original_parts")
logger.info(f"Wrote original parts extracted data to debug file: {len(original_parts_extracted_data)} original parts")
except Exception as e:
logger.warning(f"Failed to write original parts extracted data to debug file: {str(e)}")
@ -1764,11 +1764,11 @@ class ExtractionService:
debugPrefix = f"generation_contentPart_{partId}_{partLabelSafe}"
# Write prompt
self._get_service("utils").writeDebugFile(prompt, f"{debugPrefix}_prompt")
self._getService("utils").writeDebugFile(prompt, f"{debugPrefix}_prompt")
# Write response
responseContent = partResult.content if partResult.content else ""
self._get_service("utils").writeDebugFile(responseContent, f"{debugPrefix}_response")
self._getService("utils").writeDebugFile(responseContent, f"{debugPrefix}_response")
logger.debug(f"Wrote debug files for contentPart {partId} (generation): {debugPrefix}_prompt, {debugPrefix}_response")
except Exception as debugError:

View file

@ -10,13 +10,7 @@ import logging
from typing import Dict, Any, Optional
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum
# Type hint for renderer parameter
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from modules.serviceCenter.services.serviceGeneration.renderers.documentRendererBaseTemplate import BaseRenderer
_RendererLike = BaseRenderer
else:
_RendererLike = Any
from modules.serviceCenter.core.types import RendererProtocol
logger = logging.getLogger(__name__)
@ -27,7 +21,7 @@ async def buildExtractionPrompt(
title: str,
aiService=None,
services=None,
renderer: _RendererLike = None
renderer: Optional[RendererProtocol] = None
) -> str:
"""
Build unified extraction prompt for extracting content from documents.

View file

@ -26,10 +26,10 @@ class _ServicesAdapter:
Workflow is read from context dynamically so propagation updates are visible."""
def __init__(self, context, get_service: Callable[[str], Any]):
self._context = context
self._get_service = get_service
self._getService = get_service
self.user = context.user
self.mandateId = context.mandate_id
self.featureInstanceId = context.feature_instance_id
self.mandateId = context.mandateId
self.featureInstanceId = context.featureInstanceId
chat = get_service("chat")
self.interfaceDbChat = chat.interfaceDbChat
@ -39,22 +39,22 @@ class _ServicesAdapter:
@property
def chat(self):
return self._get_service("chat")
return self._getService("chat")
@property
def utils(self):
return self._get_service("utils")
return self._getService("utils")
@property
def ai(self):
return self._get_service("ai")
return self._getService("ai")
class GenerationService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with ServiceCenterContext and service resolver."""
self.services = _ServicesAdapter(context, get_service)
self._get_service = get_service
self._getService = get_service
self.interfaceDbChat = self.services.interfaceDbChat
def processActionResultDocuments(self, actionResult, action) -> List[Dict[str, Any]]:

View file

@ -112,7 +112,7 @@ def _findDsRecord(
sourceType: str,
path: str,
) -> Optional[Dict[str, Any]]:
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath
from modules.serviceCenter.core.flagResolution import normalisePath
norm = normalisePath(path)
for ds in allDs:
if (
@ -191,8 +191,8 @@ def _personalRootChildrenNodes(
mandateId = getattr(context, "mandateId", "") or ""
ctx = ServiceCenterContext(
user=context.user,
mandate_id=mandateId,
feature_instance_id="",
mandateId=mandateId,
featureInstanceId="",
)
chatService = getService("chat", ctx)
connections = chatService.getUserConnections() or []
@ -295,8 +295,8 @@ async def _connectionServiceNodes(
mandateId = getattr(context, "mandateId", "") or ""
ctx = ServiceCenterContext(
user=context.user,
mandate_id=mandateId,
feature_instance_id=instanceId,
mandateId=mandateId,
featureInstanceId=instanceId,
)
chatService = getService("chat", ctx)
securityService = getService("security", ctx)
@ -347,8 +347,8 @@ async def _browseChildNodes(
mandateId = getattr(context, "mandateId", "") or ""
ctx = ServiceCenterContext(
user=context.user,
mandate_id=mandateId,
feature_instance_id=instanceId,
mandateId=mandateId,
featureInstanceId=instanceId,
)
chatService = getService("chat", ctx)
securityService = getService("security", ctx)
@ -683,9 +683,9 @@ def _callerInstanceId(context: Any) -> str:
"""The UDB is feature-agnostic, but `_browseChildNodes` and
`_connectionServiceNodes` need a feature instance id for the
ServiceCenterContext (the underlying connector resolver wants one).
Use the caller's current feature_instance_id (workspace) when
Use the caller's current featureInstanceId (workspace) when
available, else an empty string. The id is NOT used for FDS scoping."""
fid = getattr(context, "feature_instance_id", None) or getattr(context, "featureInstanceId", None)
fid = getattr(context, "featureInstanceId", None)
return str(fid) if fid else ""

View file

@ -926,7 +926,7 @@ class KnowledgeService:
contentObjectId=f"page-{pageIdx}",
fileId=fileId,
userId=self._context.user.id if self._context.user else "",
featureInstanceId=self._context.feature_instance_id or "",
featureInstanceId=self._context.featureInstanceId or "",
contentType="text",
data=text,
contextRef={

View file

@ -172,7 +172,7 @@ def _loadRagEnabledDataSources(connectionId: str, dataSourceIds: Optional[list]
"""
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelDataSource import DataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
from modules.serviceCenter.core.flagResolution import getEffectiveFlag
rootIf = getRootInterface()
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})

View file

@ -314,7 +314,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
mandate_id=str(getattr(connection, "mandateId", "") or ""),
mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService

View file

@ -244,7 +244,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
mandate_id=str(getattr(connection, "mandateId", "") or ""),
mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService

View file

@ -297,7 +297,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
mandate_id=str(getattr(connection, "mandateId", "") or ""),
mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService

View file

@ -211,7 +211,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
mandate_id=str(getattr(connection, "mandateId", "") or ""),
mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService

View file

@ -256,7 +256,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
mandate_id=str(getattr(connection, "mandateId", "") or ""),
mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService

View file

@ -245,7 +245,7 @@ async def _resolveDependencies(connectionId: str):
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
mandate_id=str(getattr(connection, "mandateId", "") or ""),
mandateId=str(getattr(connection, "mandateId", "") or ""),
)
knowledgeService = getService("knowledge", ctx)
return adapter, connection, knowledgeService

View file

@ -30,7 +30,7 @@ def _loadRagEnabledFds(featureInstanceId: str, featureDataSourceIds: Optional[Li
"""
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds
rootIf = getRootInterface()
allFds = rootIf.db.getRecordset(
@ -118,7 +118,7 @@ async def _featureBootstrapHandler(
)
return {"featureInstanceId": featureInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"}
from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
from modules.serviceCenter.core.types import createFeatureDataProvider
from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob
from modules.serviceCenter.context import ServiceCenterContext
from modules.serviceCenter import getService
@ -156,8 +156,8 @@ async def _featureBootstrapHandler(
rootUser = getRootUser()
ctx = ServiceCenterContext(
user=rootUser,
mandate_id=mandateId,
feature_instance_id=fdsFeatureInstanceId,
mandateId=mandateId,
featureInstanceId=fdsFeatureInstanceId,
)
knowledgeService = getService("knowledge", ctx)
@ -171,7 +171,7 @@ async def _featureBootstrapHandler(
"explicitFields": set(neutralizeFields),
}
}
provider = FeatureDataProvider(
provider = createFeatureDataProvider(
dbConnector,
neutralizePolicy=neutralizePolicy,
neutralizationService=neutralizationService,

View file

@ -251,7 +251,7 @@ class _DataSourceFamilyNode(UdbNode):
def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
if not self.supportsFlag(flag):
return False
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
from modules.serviceCenter.core.flagResolution import (
resolveEffectiveForPath,
)
out = resolveEffectiveForPath(self.connectionId, self.sourceType, self.path, allDs, mode=mode)
@ -260,7 +260,7 @@ class _DataSourceFamilyNode(UdbNode):
def setFlag(self, flag, value, rootIf) -> List[str]:
from modules.datamodels.datamodelDataSource import DataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
from modules.serviceCenter.core.flagResolution import (
cascadeResetDescendants,
)
if not self.rec:
@ -416,7 +416,7 @@ class _FdsFamilyNode(UdbNode):
def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any:
if not self.supportsFlag(flag):
return None
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
from modules.serviceCenter.core.flagResolution import (
resolveEffectiveForFds,
)
out = resolveEffectiveForFds(self.featureInstanceId, self.tableName,
@ -428,7 +428,7 @@ class _FdsFamilyNode(UdbNode):
if not self.supportsFlag(flag):
raise ValueError(f"FDS does not support flag {flag!r}")
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
from modules.serviceCenter.core.flagResolution import (
cascadeResetDescendantsFds,
)
if not self.rec:
@ -669,7 +669,7 @@ class FdsFieldNode(UdbNode):
# Not explicitly overridden -> inherit from the table's effective
# neutralize. Use walk mode so the inherited value is concrete
# (never 'mixed'); a single field cannot itself be ambiguous.
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
from modules.serviceCenter.core.flagResolution import (
resolveEffectiveForFds,
)
out = resolveEffectiveForFds(
@ -753,7 +753,7 @@ def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str,
"""
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelUam import UserConnection
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath
from modules.serviceCenter.core.flagResolution import normalisePath
normPath = normalisePath(path)
@ -1007,7 +1007,7 @@ def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]:
def _findDsByCoord(rootIf: Any, connectionId: str, sourceType: Optional[str],
path: str) -> Optional[Dict[str, Any]]:
from modules.datamodels.datamodelDataSource import DataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath
from modules.serviceCenter.core.flagResolution import normalisePath
rf = {"connectionId": connectionId}
if sourceType is not None:
rf["sourceType"] = sourceType

View file

@ -33,7 +33,7 @@ class _ServicesAdapter:
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
self.interfaceDbComponent = getComponentInterface(
context.user,
mandateId=context.mandate_id
mandateId=context.mandateId
)

View file

@ -24,13 +24,13 @@ class SharepointService:
"""Initialize SharePoint service without access token.
Args:
context: ServiceCenterContext with user, mandate_id, etc.
context: ServiceCenterContext with user, mandateId, etc.
get_service: Service resolver for dependency injection (e.g. security)
Use setAccessTokenFromConnection() method to configure the access token before making API calls.
"""
self._context = context
self._get_service = get_service
self._getService = get_service
self.accessToken = None
self.baseUrl = "https://graph.microsoft.com/v1.0"
@ -59,7 +59,7 @@ class SharepointService:
return False
# Get a fresh token for this specific connection via security service
security = self._get_service("security")
security = self._getService("security")
if not security:
logger.error("Security service not available for token access")
return False

View file

@ -55,11 +55,11 @@ class SubscriptionService:
if mandateId is not None and callable(mandateId):
ctx = contextOrUser
self.currentUser = ctx.user
self.mandateId = ctx.mandate_id or ""
self.mandateId = ctx.mandateId or ""
elif get_service is not None and hasattr(contextOrUser, "user"):
ctx = contextOrUser
self.currentUser = ctx.user
self.mandateId = ctx.mandate_id or ""
self.mandateId = ctx.mandateId or ""
else:
self.currentUser = contextOrUser
self.mandateId = mandateId or ""

View file

@ -15,7 +15,7 @@ class TicketService:
def __init__(self, context, get_service: Callable[[str], Any]):
"""Initialize with context and service resolver."""
self._context = context
self._get_service = get_service
self._getService = get_service
async def connectTicket(
self,

View file

@ -22,14 +22,14 @@ class WebService:
def __init__(self, context, get_service):
"""Initialize webcrawl service with context and service resolver."""
self._context = context
self._get_service = get_service
self._getService = get_service
def _workflow_id(self):
"""Get workflow ID for operation IDs."""
if self._context.workflow:
return self._context.workflow.id
if self._context.workflow_id:
return self._context.workflow_id
if self._context.workflowId:
return self._context.workflowId
return f"no-workflow-{int(time.time())}"
async def performWebResearch(
@ -61,7 +61,7 @@ class WebService:
"""
# Start progress tracking if operationId provided
if operationId:
self._get_service("chat").progressLogStart(
self._getService("chat").progressLogStart(
operationId,
"Web Research",
"Research",
@ -71,7 +71,7 @@ class WebService:
try:
# Step 1: AI intention analysis - extract URLs and parameters from prompt
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.1, "Analyzing research intent")
self._getService("chat").progressLogUpdate(operationId, 0.1, "Analyzing research intent")
analysisResult = await self._analyzeResearchIntent(prompt, urls, country, language, researchDepth)
@ -99,7 +99,7 @@ class WebService:
searchResultsWithContent = []
if needsSearch and (not allUrls or len(allUrls) < maxNumberPages):
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.3, "Searching for URLs and content")
self._getService("chat").progressLogUpdate(operationId, 0.3, "Searching for URLs and content")
try:
searchUrls, searchResultsWithContent = await self._performWebSearch(
@ -121,7 +121,7 @@ class WebService:
logger.warning("Tavily search returned no URLs, using AI-extracted URLs only")
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.5, f"Found {len(allUrls)} total URLs")
self._getService("chat").progressLogUpdate(operationId, 0.5, f"Found {len(allUrls)} total URLs")
# If we have search results (even without content), use them directly instead of crawling
# Tavily search results are more relevant than generic AI-extracted URLs
@ -179,7 +179,7 @@ class WebService:
"total_urls": len(searchUrls),
"urls_with_content": urlsWithContent,
"total_content_length": totalContentLength,
"search_date": self._get_service("utils").timestampGetUtc()
"search_date": self._getService("utils").timestampGetUtc()
},
"sections": sections,
"statistics": {
@ -201,8 +201,8 @@ class WebService:
result["metadata"]["suggested_filename"] = suggestedFilename
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.9, "Completed")
self._get_service("chat").progressLogFinish(operationId, True)
self._getService("chat").progressLogUpdate(operationId, 0.9, "Completed")
self._getService("chat").progressLogFinish(operationId, True)
return result
@ -231,8 +231,8 @@ class WebService:
# Step 5: Crawl all URLs with hierarchical logging
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.4, "Initiating")
self._get_service("chat").progressLogUpdate(operationId, 0.6, f"Crawling {len(validatedUrls)} URLs")
self._getService("chat").progressLogUpdate(operationId, 0.4, "Initiating")
self._getService("chat").progressLogUpdate(operationId, 0.6, f"Crawling {len(validatedUrls)} URLs")
# Use parent operation ID directly (parentId should be operationId, not log entry ID)
parentOperationId = operationId # Use the parent's operationId directly
@ -246,9 +246,9 @@ class WebService:
)
if operationId:
self._get_service("chat").progressLogUpdate(operationId, 0.9, "Consolidating results")
self._get_service("chat").progressLogUpdate(operationId, 0.95, "Completed")
self._get_service("chat").progressLogFinish(operationId, True)
self._getService("chat").progressLogUpdate(operationId, 0.9, "Consolidating results")
self._getService("chat").progressLogUpdate(operationId, 0.95, "Completed")
self._getService("chat").progressLogFinish(operationId, True)
# Calculate statistics about crawl results
totalResults = len(crawlResult) if isinstance(crawlResult, list) else 1
@ -317,7 +317,7 @@ class WebService:
"total_urls": len(validatedUrls),
"urls_with_content": urlsWithContent,
"total_content_length": totalContentLength,
"crawl_date": self._get_service("utils").timestampGetUtc()
"crawl_date": self._getService("utils").timestampGetUtc()
},
"sections": sections,
"statistics": {
@ -345,7 +345,7 @@ class WebService:
except Exception as e:
logger.error(f"Error in web research: {str(e)}")
if operationId:
self._get_service("chat").progressLogFinish(operationId, False)
self._getService("chat").progressLogFinish(operationId, False)
raise
async def _analyzeResearchIntent(
@ -397,13 +397,13 @@ Return ONLY valid JSON, no additional text:
try:
# Call AI planning to analyze intent
analysisJson = await self._get_service("ai").callAiPlanning(
analysisJson = await self._getService("ai").callAiPlanning(
analysisPrompt,
debugType="webresearchintent"
)
# Extract JSON from response (handles markdown code blocks)
extractedJson = self._get_service("utils").jsonExtractString(analysisJson)
extractedJson = self._getService("utils").jsonExtractString(analysisJson)
if not extractedJson:
raise ValueError("No JSON found in AI response")
@ -454,7 +454,7 @@ Return ONLY valid JSON, no additional text:
searchPrompt = searchPromptModel.model_dump_json(exclude_none=True, indent=2)
# Debug: persist search prompt
self._get_service("utils").writeDebugFile(searchPrompt, "websearch_prompt")
self._getService("utils").writeDebugFile(searchPrompt, "websearch_prompt")
# Call AI with WEB_SEARCH_DATA operation
searchOptions = AiCallOptions(
@ -463,7 +463,7 @@ Return ONLY valid JSON, no additional text:
)
# Use unified callAiContent method
searchResponse = await self._get_service("ai").callAiContent(
searchResponse = await self._getService("ai").callAiContent(
prompt=searchPrompt,
options=searchOptions,
outputFormat="json"
@ -518,16 +518,16 @@ Return ONLY valid JSON, no additional text:
# Debug: persist search response
if isinstance(searchResult, str):
self._get_service("utils").writeDebugFile(searchResult, "websearch_response")
self._getService("utils").writeDebugFile(searchResult, "websearch_response")
logger.debug(f"Search response (first 500 chars): {searchResult[:500]}")
else:
self._get_service("utils").writeDebugFile(json.dumps(searchResult, indent=2), "websearch_response")
self._getService("utils").writeDebugFile(json.dumps(searchResult, indent=2), "websearch_response")
logger.debug(f"Search response type: {type(searchResult)}, keys: {list(searchResult.keys()) if isinstance(searchResult, dict) else 'N/A'}")
# Parse and extract URLs and content
if isinstance(searchResult, str):
# Extract JSON from response (handles markdown code blocks)
extractedJson = self._get_service("utils").jsonExtractString(searchResult)
extractedJson = self._getService("utils").jsonExtractString(searchResult)
if extractedJson:
try:
searchData = json.loads(extractedJson)
@ -800,7 +800,7 @@ Return ONLY valid JSON, no additional text:
if parentOperationId:
workflowId = self._workflow_id()
urlOperationId = f"web_crawl_url_{workflowId}_{urlIndex}_{int(time.time())}"
self._get_service("chat").progressLogStart(
self._getService("chat").progressLogStart(
urlOperationId,
"Web Crawl",
f"URL {urlIndex + 1}/{totalUrls}",
@ -813,8 +813,8 @@ Return ONLY valid JSON, no additional text:
if urlOperationId:
displayUrl = url[:50] + "..." if len(url) > 50 else url
self._get_service("chat").progressLogUpdate(urlOperationId, 0.2, f"Crawling: {displayUrl}")
self._get_service("chat").progressLogUpdate(urlOperationId, 0.3, "Initiating crawl")
self._getService("chat").progressLogUpdate(urlOperationId, 0.2, f"Crawling: {displayUrl}")
self._getService("chat").progressLogUpdate(urlOperationId, 0.3, "Initiating crawl")
# Build crawl prompt model for single URL
# maxWidth is passed from performWebResearch based on researchDepth
@ -829,7 +829,7 @@ Return ONLY valid JSON, no additional text:
# Debug: persist crawl prompt (with URL identifier in content for clarity)
debugPrompt = f"URL: {url}\n\n{crawlPrompt}"
self._get_service("utils").writeDebugFile(debugPrompt, "webcrawl_prompt")
self._getService("utils").writeDebugFile(debugPrompt, "webcrawl_prompt")
# Call AI with WEB_CRAWL operation
crawlOptions = AiCallOptions(
@ -838,10 +838,10 @@ Return ONLY valid JSON, no additional text:
)
if urlOperationId:
self._get_service("chat").progressLogUpdate(urlOperationId, 0.4, "Calling crawl connector")
self._getService("chat").progressLogUpdate(urlOperationId, 0.4, "Calling crawl connector")
# Use unified callAiContent method with parentOperationId for hierarchical logging
crawlResponse = await self._get_service("ai").callAiContent(
crawlResponse = await self._getService("ai").callAiContent(
prompt=crawlPrompt,
options=crawlOptions,
outputFormat="json",
@ -849,22 +849,22 @@ Return ONLY valid JSON, no additional text:
)
if urlOperationId:
self._get_service("chat").progressLogUpdate(urlOperationId, 0.7, "Processing crawl results")
self._getService("chat").progressLogUpdate(urlOperationId, 0.7, "Processing crawl results")
# Extract content from AiResponse
crawlResult = crawlResponse.content
# Debug: persist crawl response
if isinstance(crawlResult, str):
self._get_service("utils").writeDebugFile(crawlResult, "webcrawl_response")
self._getService("utils").writeDebugFile(crawlResult, "webcrawl_response")
else:
self._get_service("utils").writeDebugFile(json.dumps(crawlResult, indent=2), "webcrawl_response")
self._getService("utils").writeDebugFile(json.dumps(crawlResult, indent=2), "webcrawl_response")
# Parse crawl result
if isinstance(crawlResult, str):
try:
# Extract JSON from response (handles markdown code blocks)
extractedJson = self._get_service("utils").jsonExtractString(crawlResult)
extractedJson = self._getService("utils").jsonExtractString(crawlResult)
crawlData = json.loads(extractedJson) if extractedJson else json.loads(crawlResult)
except:
crawlData = {"url": url, "content": crawlResult}
@ -873,7 +873,7 @@ Return ONLY valid JSON, no additional text:
# Process crawl results and create hierarchical progress logging for sub-URLs
if urlOperationId:
self._get_service("chat").progressLogUpdate(urlOperationId, 0.8, "Processing crawl results")
self._getService("chat").progressLogUpdate(urlOperationId, 0.8, "Processing crawl results")
# Recursively process crawl results to find nested URLs and create child operations
processedResults = self._processCrawlResultsWithHierarchy(crawlData, url, urlOperationId, maxDepth, 0)
@ -891,17 +891,17 @@ Return ONLY valid JSON, no additional text:
if urlOperationId:
if totalUrlsCrawled > 1:
self._get_service("chat").progressLogUpdate(urlOperationId, 0.9, f"Crawled {totalUrlsCrawled} URLs (including sub-URLs)")
self._getService("chat").progressLogUpdate(urlOperationId, 0.9, f"Crawled {totalUrlsCrawled} URLs (including sub-URLs)")
else:
self._get_service("chat").progressLogUpdate(urlOperationId, 0.9, "Crawl completed")
self._get_service("chat").progressLogFinish(urlOperationId, True)
self._getService("chat").progressLogUpdate(urlOperationId, 0.9, "Crawl completed")
self._getService("chat").progressLogFinish(urlOperationId, True)
return results
except Exception as e:
logger.error(f"Error crawling URL {url}: {str(e)}")
if urlOperationId:
self._get_service("chat").progressLogFinish(urlOperationId, False)
self._getService("chat").progressLogFinish(urlOperationId, False)
return [{"url": url, "error": str(e)}]
def _processCrawlResultsWithHierarchy(
@ -943,7 +943,7 @@ Return ONLY valid JSON, no additional text:
# This is a sub-URL - create child operation
workflowId = self._workflow_id()
subUrlOperationId = f"{parentOperationId}_sub_{idx}_{int(time.time())}"
self._get_service("chat").progressLogStart(
self._getService("chat").progressLogStart(
subUrlOperationId,
"Crawling Sub-URL",
f"Depth {currentDepth + 1}",
@ -969,12 +969,12 @@ Return ONLY valid JSON, no additional text:
)
item["subUrls"] = nestedResults
self._get_service("chat").progressLogUpdate(subUrlOperationId, 0.9, "Completed")
self._get_service("chat").progressLogFinish(subUrlOperationId, True)
self._getService("chat").progressLogUpdate(subUrlOperationId, 0.9, "Completed")
self._getService("chat").progressLogFinish(subUrlOperationId, True)
except Exception as e:
logger.error(f"Error processing sub-URL {itemUrl}: {str(e)}")
if subUrlOperationId:
self._get_service("chat").progressLogFinish(subUrlOperationId, False)
self._getService("chat").progressLogFinish(subUrlOperationId, False)
results.append(item)
else:

View file

@ -6,7 +6,9 @@ Higher-layer system components (e.g. workflowAutomation) register their
lifecycle hooks here at boot time via ``app.py`` (Composition Root, L7).
Interface modules read the registry generically no upward imports needed.
Supported events: ``onBootstrap``, ``onMandateDelete``, ``onInstanceCreate``.
Supported events: ``onBootstrap``, ``onMandateDelete``, ``onMandateProvision``,
``onInstanceCreate``, ``onUserMandateCreate``, ``onUserMandateDelete``,
``onUserBudgetAdjust``, ``onStorageChanged``.
This is the same inversion pattern used by
``serviceAgent/externalToolRegistry.py`` for agent tools.

View file

@ -0,0 +1,102 @@
# Copyright (c) 2025 Patrick Motsch
"""Shared value-kind resolution helpers.
Extracted from conditionOperators so that upstreamPathsService can resolve
value kinds without importing conditionOperators (breaking the bidirectional
import cycle).
"""
from __future__ import annotations
from typing import Any, Dict, List
def catalogTypeToValueKind(catalogType: str) -> str:
"""Map port-catalog / dataPickOptions type strings to condition valueKind."""
ct = (catalogType or "").strip()
if not ct or ct == "Any":
return "unknown"
low = ct.lower()
if low in ("str", "string", "email", "url"):
return "string"
if low in ("int", "float", "number"):
return "number"
if low == "bool":
return "boolean"
if low in ("date", "datetime", "timestamp"):
return "datetime"
if low.startswith("list[") or low == "list":
return "array"
if low.startswith("dict") or low == "dict":
return "object"
if low in ("file", "actiondocument", "fileref"):
return "file"
return "unknown"
def _isContextProducer(nodeType: str) -> bool:
return nodeType in ("context.extractContent", "context.mergeContext", "context.setContext")
def _pathSuggestsContext(path: List[Any], producerType: str) -> bool:
if not path:
return _isContextProducer(producerType)
last = str(path[-1])
if last in ("data", "files", "merged", "presentation"):
return True
if "files" in [str(p) for p in path]:
return True
if _isContextProducer(producerType) and path[0] in ("data", "response", "merged"):
return True
return False
def _pathSuggestsFile(path: List[Any], producerType: str) -> bool:
pathStr = [str(p) for p in path]
if producerType == "input.upload":
return True
if "file" in pathStr or "documents" in pathStr or "mimeType" in pathStr or "fileName" in pathStr:
return True
if producerType.startswith("sharepoint.") and "file" in pathStr:
return True
return False
def _pathsEqual(a: List[Any], b: List[Any]) -> bool:
if len(a) != len(b):
return False
return all(str(x) == str(y) for x, y in zip(a, b))
def resolveValueKindFromRef(graph: Dict[str, Any], ref: Dict[str, Any]) -> str:
"""Resolve condition valueKind using graph-local heuristics only.
Unlike ``conditionOperators.resolve_value_kind`` this does NOT call
``compute_upstream_paths``, so it is safe to import from
upstreamPathsService without creating a cycle.
"""
if not isinstance(ref, dict):
return "unknown"
producerId = ref.get("nodeId")
path = ref.get("path") or []
if not isinstance(path, list):
path = []
if not producerId:
return "unknown"
nodes = graph.get("nodes") or []
nodeById = {n.get("id"): n for n in nodes if n.get("id")}
producer = nodeById.get(producerId) or {}
producerType = str(producer.get("type") or "")
if _pathSuggestsContext(path, producerType):
return "context"
if _pathSuggestsFile(path, producerType):
tail = str(path[-1]) if path else ""
if tail in ("mimeType", "fileName"):
return "string"
return "file"
if producerType in ("trigger.form", "input.form") and path and str(path[0]) == "payload":
return "string"
return "unknown"

View file

@ -10,6 +10,12 @@ from typing import Any, Dict, List, Optional, Tuple
from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
from modules.shared.i18nRegistry import resolveText, t
from modules.workflowAutomation.editor._valueKindResolver import (
catalogTypeToValueKind as catalog_type_to_value_kind,
_pathSuggestsContext as _path_suggests_context,
_pathSuggestsFile as _path_suggests_file,
_pathsEqual as _paths_equal,
)
logger = logging.getLogger(__name__)
@ -200,64 +206,7 @@ def localize_operator_catalog(lang: str = "de") -> Dict[str, List[Dict[str, Any]
return out
def catalog_type_to_value_kind(catalog_type: str) -> str:
"""Map port-catalog / dataPickOptions type strings to condition valueKind."""
ct = (catalog_type or "").strip()
if not ct or ct == "Any":
return "unknown"
low = ct.lower()
if low in ("str", "string", "email", "url"):
return "string"
if low in ("int", "float", "number"):
return "number"
if low == "bool":
return "boolean"
if low in ("date", "datetime", "timestamp"):
return "datetime"
if low.startswith("list[") or low == "list":
return "array"
if low.startswith("dict") or low == "dict":
return "object"
if low in ("file", "actiondocument", "fileref"):
return "file"
return "unknown"
def _paths_equal(a: List[Any], b: List[Any]) -> bool:
if len(a) != len(b):
return False
return all(str(x) == str(y) for x, y in zip(a, b))
def _is_context_producer(node_type: str) -> bool:
return node_type in ("context.extractContent", "context.mergeContext", "context.setContext")
def _path_suggests_context(path: List[Any], producer_type: str) -> bool:
if not path:
return _is_context_producer(producer_type)
last = str(path[-1])
if last in ("data", "files", "merged", "presentation"):
return True
if "files" in [str(p) for p in path]:
return True
if _is_context_producer(producer_type) and path[0] in ("data", "response", "merged"):
return True
return False
def _path_suggests_file(path: List[Any], producer_type: str) -> bool:
path_str = [str(p) for p in path]
if producer_type == "input.upload":
return True
if "file" in path_str or "documents" in path_str or "mimeType" in path_str or "fileName" in path_str:
return True
if producer_type.startswith("sharepoint.") and "file" in path_str:
return True
return False
def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upstream: bool = False) -> str:
def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any]) -> str:
"""Resolve condition valueKind for a DataRef against the workflow graph."""
if not isinstance(ref, dict):
return "unknown"
@ -281,32 +230,31 @@ def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upst
return "string"
return "file"
if not _skip_upstream:
from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths
from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths
target_id = graph.get("targetNodeId") or producer_id
matched_type: Optional[str] = None
target_id = graph.get("targetNodeId") or producer_id
matched_type: Optional[str] = None
for entry in compute_upstream_paths(graph, target_id):
if entry.get("producerNodeId") != producer_id:
continue
entry_path = entry.get("path") or []
if _paths_equal(list(entry_path), list(path)):
matched_type = str(entry.get("type") or "Any")
break
if matched_type is None and path:
parent_path = list(path[:-1])
for entry in compute_upstream_paths(graph, target_id):
if entry.get("producerNodeId") != producer_id:
continue
entry_path = entry.get("path") or []
if _paths_equal(list(entry_path), list(path)):
if _paths_equal(list(entry.get("path") or []), parent_path):
matched_type = str(entry.get("type") or "Any")
break
if matched_type is None and path:
parent_path = list(path[:-1])
for entry in compute_upstream_paths(graph, target_id):
if entry.get("producerNodeId") != producer_id:
continue
if _paths_equal(list(entry.get("path") or []), parent_path):
matched_type = str(entry.get("type") or "Any")
break
if matched_type:
vk = catalog_type_to_value_kind(matched_type)
if vk != "unknown":
return vk
if matched_type:
vk = catalog_type_to_value_kind(matched_type)
if vk != "unknown":
return vk
if producer_type in ("trigger.form", "input.form") and path and str(path[0]) == "payload":
return "string"

View file

@ -4,7 +4,10 @@ from __future__ import annotations
from typing import Any, Dict, List, Set
from modules.workflowAutomation.editor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind
from modules.workflowAutomation.editor._valueKindResolver import (
catalogTypeToValueKind,
resolveValueKindFromRef,
)
from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES
from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema
from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds
@ -170,14 +173,14 @@ def compute_upstream_paths(graph: Dict[str, Any], target_node_id: str) -> List[D
for entry in paths:
ct = str(entry.get("type") or "Any")
vk = catalog_type_to_value_kind(ct)
vk = catalogTypeToValueKind(ct)
if vk == "unknown":
ref = {
"nodeId": entry.get("producerNodeId"),
"path": entry.get("path") or [],
}
graph_with_target = {**graph, "targetNodeId": target_node_id}
vk = resolve_value_kind(graph_with_target, ref, _skip_upstream=True)
vk = resolveValueKindFromRef(graph_with_target, ref)
entry["valueKind"] = vk
return paths

View file

@ -0,0 +1,118 @@
# Copyright (c) 2025 Patrick Motsch
"""Run failure notification helpers.
Extracted from scheduler/mainScheduler to break the bidirectional import
cycle between executionEngine and mainScheduler. The engine calls
``notifyRunFailed`` directly (same subfolder, no cycle).
"""
import logging
from typing import Optional
from modules.shared.eventManagement import eventManager
logger = logging.getLogger(__name__)
def notifyRunFailed(
workflowId: str,
runId: str,
error: str,
mandateId: str = None,
workflowLabel: str = None,
) -> None:
"""Notify on workflow run failure: emit event, create in-app notification, trigger email subscription."""
try:
eventManager.emit("workflowAutomation.run.failed", {
"workflowId": workflowId,
"runId": runId,
"error": error,
"mandateId": mandateId,
})
logger.info("Emitted run.failed event for run %s (workflow %s)", runId, workflowId)
except Exception as e:
logger.warning("Failed to emit run.failed event: %s", e)
_createRunFailedNotification(workflowId, runId, error, mandateId, workflowLabel)
_triggerRunFailedSubscription(workflowId, runId, error, mandateId, workflowLabel)
def _createRunFailedNotification(
workflowId: str,
runId: str,
error: str,
mandateId: str = None,
workflowLabel: str = None,
) -> None:
"""Create in-app notification for the workflow creator."""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelNotification import UserNotification, NotificationType, NotificationStatus
rootInterface = getRootInterface()
if not rootInterface:
return
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
eventUser = rootInterface.getUserByUsername("event")
if not eventUser:
return
iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "")
wf = iface.getWorkflow(workflowId)
if not wf:
return
creatorId = wf.get("sysCreatedBy") if isinstance(wf, dict) else getattr(wf, "sysCreatedBy", None)
if not creatorId:
return
label = workflowLabel or (wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", ""))
notification = UserNotification(
userId=creatorId,
type=NotificationType.SYSTEM,
status=NotificationStatus.UNREAD,
title="Workflow fehlgeschlagen",
message=f"Workflow '{label or workflowId}' ist fehlgeschlagen: {error[:200]}",
referenceType="AutoRun",
referenceId=runId,
icon="alert-triangle",
)
rootInterface.db.recordCreate(
model_class=UserNotification,
record=notification.model_dump(),
)
logger.info("Created in-app notification for user %s (run %s)", creatorId, runId)
except Exception as e:
logger.warning("Failed to create in-app run.failed notification: %s", e)
_onRunFailedCallback = None
def setOnRunFailedCallback(callback) -> None:
"""Set the callback for run failure notifications (injected by app.py)."""
global _onRunFailedCallback
_onRunFailedCallback = callback
def _triggerRunFailedSubscription(
workflowId: str,
runId: str,
error: str,
mandateId: str = None,
workflowLabel: str = None,
) -> None:
"""Trigger the messaging subscription for run failures via injected callback."""
if _onRunFailedCallback is None:
return
try:
_onRunFailedCallback(
workflowId=workflowId,
runId=runId,
error=error,
mandateId=mandateId,
workflowLabel=workflowLabel,
)
except Exception as e:
logger.warning("Failed to trigger run.failed subscription: %s", e)

View file

@ -1540,15 +1540,6 @@ async def executeGraph(
duration_ms=_emailPauseMs,
)
logger.info("executeGraph paused for email wait (run %s, node %s)", e.runId, e.nodeId)
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.workflowAutomation.scheduler.emailPoller import ensureRunning
root = getRootInterface()
event_user = root.getUserByUsername("event") if root else None
if event_user:
ensureRunning(event_user)
except Exception as poll_err:
logger.warning("Could not start email poller: %s", poll_err)
paused_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
run_ctx = {
"connectionMap": context.get("connectionMap"),
@ -1612,7 +1603,7 @@ async def executeGraph(
) if _wfObj else {}
_shouldNotify = _wfDict.get("notifyOnFailure", True) if _wfDict else True
if _shouldNotify:
from modules.workflowAutomation.scheduler.mainScheduler import notifyRunFailed
from modules.workflowAutomation.engine._runNotifications import notifyRunFailed
notifyRunFailed(
workflowId or "", runId or "", str(e),
mandateId=mandateId,

View file

@ -383,6 +383,21 @@ def _pathContainsWildcard(path: List[Any]) -> bool:
# (``featureInstanceRefMigration.materializeFeatureInstanceRefs``) writes the
# envelope, the resolver unwraps it on its way to the action.
_STALE_FILE_CREATE_CONTEXT_PATHS = frozenset({
("responseData",),
("response",),
("merged",),
("documents", 0, "documentData"),
})
def remap_stale_presentation_ref_path(path: List[Any]) -> List[Any]:
"""Map legacy text-handover paths to unified presentation ``data``."""
if tuple(path) in _STALE_FILE_CREATE_CONTEXT_PATHS:
return ["data"]
return list(path)
_TYPED_REF_PRIMARY_FIELD = {
"FeatureInstanceRef": "id",
"ConnectionRef": "id",
@ -450,9 +465,6 @@ def resolveParameterReferences(
plist = list(path)
resolved = _get_by_path(data, plist)
if resolved is None:
from modules.workflowAutomation.engine.pickNotPushMigration import (
remap_stale_presentation_ref_path,
)
alt_path = remap_stale_presentation_ref_path(plist)
if alt_path != plist:
resolved = _get_by_path(data, alt_path)

View file

@ -21,7 +21,11 @@ from modules.nodeCatalog.portTypes import (
PRIMARY_TEXT_HANDOVER_REF_PATH,
resolve_output_schema_name,
)
from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources
from modules.workflowAutomation.engine.graphUtils import (
buildConnectionMap,
getInputSources,
remap_stale_presentation_ref_path,
)
logger = logging.getLogger(__name__)
@ -243,20 +247,6 @@ def materializeRecommendedDataPickRef(graph: Dict[str, Any]) -> Dict[str, Any]:
return g
_STALE_FILE_CREATE_CONTEXT_PATHS = frozenset({
("responseData",),
("response",),
("merged",),
("documents", 0, "documentData"),
})
def remap_stale_presentation_ref_path(path: List[Any]) -> List[Any]:
"""Map legacy text-handover paths to unified presentation ``data``."""
if tuple(path) in _STALE_FILE_CREATE_CONTEXT_PATHS:
return ["data"]
return list(path)
def _normalize_presentation_refs_in_value(val: Any) -> Any:
"""Rewrite stale ref paths inside ``contextBuilder`` lists or bare refs."""

View file

@ -57,8 +57,8 @@ def _getWorkflowAutomationServices(
ctx = ServiceCenterContext(
user=user,
mandate_id=mandateId,
feature_instance_id=featureInstanceId,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
workflow=_workflow,
)
return ServicesBag(ctx, lambda key: getService(key, ctx))

View file

@ -6,6 +6,8 @@ from modules.workflowAutomation.scheduler.mainScheduler import (
stop,
syncNow,
setMainLoop,
)
from modules.workflowAutomation.engine._runNotifications import (
notifyRunFailed,
setOnRunFailedCallback,
)

View file

@ -263,6 +263,12 @@ class WorkflowScheduler:
"WorkflowScheduler: executed workflow %s success=%s paused=%s",
workflowId, result.get("success"), result.get("paused"),
)
if result.get("waitReason") == "email":
try:
from modules.workflowAutomation.scheduler.emailPoller import ensureRunning
ensureRunning(eventUser)
except Exception as pollErr:
logger.warning("WorkflowScheduler: could not start email poller: %s", pollErr)
except Exception as e:
logger.exception("WorkflowScheduler: failed to execute workflow %s: %s", workflowId, e)
@ -333,94 +339,10 @@ def _cronToIntervalSeconds(cron: str):
return None
def notifyRunFailed(workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None) -> None:
"""Notify on workflow run failure: emit event, create in-app notification, trigger email subscription."""
try:
eventManager.emit("workflowAutomation.run.failed", {
"workflowId": workflowId,
"runId": runId,
"error": error,
"mandateId": mandateId,
})
logger.info("Emitted run.failed event for run %s (workflow %s)", runId, workflowId)
except Exception as e:
logger.warning("Failed to emit run.failed event: %s", e)
_createRunFailedNotification(workflowId, runId, error, mandateId, workflowLabel)
_triggerRunFailedSubscription(workflowId, runId, error, mandateId, workflowLabel)
def _createRunFailedNotification(
workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None
) -> None:
"""Create in-app notification for the workflow creator."""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelNotification import UserNotification, NotificationType, NotificationStatus
rootInterface = getRootInterface()
if not rootInterface:
return
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
eventUser = rootInterface.getUserByUsername("event")
if not eventUser:
return
iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "")
wf = iface.getWorkflow(workflowId)
if not wf:
return
creatorId = wf.get("sysCreatedBy") if isinstance(wf, dict) else getattr(wf, "sysCreatedBy", None)
if not creatorId:
return
label = workflowLabel or (wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", ""))
notification = UserNotification(
userId=creatorId,
type=NotificationType.SYSTEM,
status=NotificationStatus.UNREAD,
title="Workflow fehlgeschlagen",
message=f"Workflow '{label or workflowId}' ist fehlgeschlagen: {error[:200]}",
referenceType="AutoRun",
referenceId=runId,
icon="alert-triangle",
)
rootInterface.db.recordCreate(
model_class=UserNotification,
record=notification.model_dump(),
)
logger.info("Created in-app notification for user %s (run %s)", creatorId, runId)
except Exception as e:
logger.warning("Failed to create in-app run.failed notification: %s", e)
_onRunFailedCallback = None
def setOnRunFailedCallback(callback) -> None:
"""Set the callback for run failure notifications (injected by app.py)."""
global _onRunFailedCallback
_onRunFailedCallback = callback
def _triggerRunFailedSubscription(
workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None
) -> None:
"""Trigger the messaging subscription for run failures via injected callback."""
if _onRunFailedCallback is None:
return
try:
_onRunFailedCallback(
workflowId=workflowId,
runId=runId,
error=error,
mandateId=mandateId,
workflowLabel=workflowLabel,
)
except Exception as e:
logger.warning("Failed to trigger run.failed subscription: %s", e)
from modules.workflowAutomation.engine._runNotifications import ( # noqa: E402 — re-export
notifyRunFailed,
setOnRunFailedCallback,
)
# Module-level singleton

View file

@ -41,7 +41,7 @@ def generateDynamicPlanSelectionPrompt(services, context: Any, learningEngine=No
# Add adaptive learning context if available
adaptiveContext = {}
if learningEngine:
workflowId = getattr(context, 'workflow_id', 'unknown')
workflowId = getattr(context, 'workflowId', 'unknown')
userPrompt = extractUserPrompt(context)
adaptiveContext = learningEngine.getAdaptiveContextForActionSelection(workflowId, userPrompt)
@ -226,7 +226,7 @@ Excludes documents/connections/history entirely.
# Add adaptive learning context if available
adaptiveContext = {}
if learningEngine:
workflowId = getattr(context, 'workflow_id', 'unknown')
workflowId = getattr(context, 'workflowId', 'unknown')
adaptiveContext = learningEngine.getAdaptiveContextForParameters(workflowId, compoundActionName, parametersContext or "")
if adaptiveContext:

View file

@ -78,7 +78,7 @@ def _buildInterface(db: _FakeDb) -> AppObjects:
def _stubCopySystemRoles():
"""Avoid touching the bootstrap module (which would need a real DB)."""
with patch(
"modules.interfaces.interfaceBootstrap.copySystemRolesToMandate",
"modules.interfaces.interfaceRbac.copySystemRolesToMandate",
return_value=0,
):
yield