diff --git a/app.py b/app.py index 472de2a1..6944b144 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,7 @@ from datetime import datetime from modules.shared.configuration import APP_CONFIG from modules.shared.eventManagement import eventManager from modules.features import featuresLifecycle as featuresLifecycle -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface class DailyRotatingFileHandler(RotatingFileHandler): """ @@ -421,8 +421,8 @@ app.include_router(promptRouter) from modules.routes.routeDataConnections import router as connectionsRouter app.include_router(connectionsRouter) -from modules.routes.routeWorkflows import router as workflowRouter -app.include_router(workflowRouter) +from modules.routes.routeDataWorkflows import router as dataWorkflowsRouter +app.include_router(dataWorkflowsRouter) from modules.routes.routeFeatureChatDynamic import router as chatPlaygroundRouter app.include_router(chatPlaygroundRouter) @@ -451,11 +451,11 @@ app.include_router(sharepointRouter) from modules.routes.routeDataAutomation import router as automationRouter app.include_router(automationRouter) -from modules.routes.routeFeatureWorkflow import router as adminAutomationEventsRouter +from modules.routes.routeAdminAutomationEvents import router as adminAutomationEventsRouter app.include_router(adminAutomationEventsRouter) -from modules.routes.routeRbac import router as rbacRouter -app.include_router(rbacRouter) +from modules.routes.routeAdminRbacRules import router as rbacAdminRulesRouter +app.include_router(rbacAdminRulesRouter) from modules.routes.routeOptions import router as optionsRouter app.include_router(optionsRouter) @@ -470,14 +470,14 @@ from modules.routes.routeFeatureTrustee import router as trusteeRouter app.include_router(trusteeRouter) # Phase 8: New Feature Routes -from modules.routes.routeFeatures import router as featuresRouter -app.include_router(featuresRouter) +from modules.routes.routeAdminFeatures import router as featuresAdminRouter +app.include_router(featuresAdminRouter) from modules.routes.routeInvitations import router as invitationsRouter app.include_router(invitationsRouter) -from modules.routes.routeRbacExport import router as rbacExportRouter -app.include_router(rbacExportRouter) +from modules.routes.routeAdminRbacExport import router as rbacAdminExportRouter +app.include_router(rbacAdminExportRouter) from modules.routes.routeGdpr import router as gdprRouter app.include_router(gdprRouter) diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index f6cf0f0d..c6eaafad 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -21,7 +21,7 @@ from slowapi.util import get_remote_address from modules.shared.configuration import APP_CONFIG from modules.security.rootAccess import getRootDbAppConnector, getRootUser -from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface +from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelRbac import AccessRule diff --git a/modules/auth/tokenManager.py b/modules/auth/tokenManager.py index 322b2e4e..0fd76092 100644 --- a/modules/auth/tokenManager.py +++ b/modules/auth/tokenManager.py @@ -259,7 +259,7 @@ class TokenManager: try: if interface is None: from modules.security.rootAccess import getRootUser - from modules.interfaces.interfaceDbAppObjects import getInterface + from modules.interfaces.interfaceDbApp import getInterface rootUser = getRootUser() interface = getInterface(rootUser) diff --git a/modules/auth/tokenRefreshService.py b/modules/auth/tokenRefreshService.py index 2d780364..a4f8e402 100644 --- a/modules/auth/tokenRefreshService.py +++ b/modules/auth/tokenRefreshService.py @@ -159,7 +159,7 @@ class TokenRefreshService: # Get user interface from modules.security.rootAccess import getRootUser - from modules.interfaces.interfaceDbAppObjects import getInterface + from modules.interfaces.interfaceDbApp import getInterface rootUser = getRootUser() root_interface = getInterface(rootUser) @@ -228,7 +228,7 @@ class TokenRefreshService: # Get user interface from modules.security.rootAccess import getRootUser - from modules.interfaces.interfaceDbAppObjects import getInterface + from modules.interfaces.interfaceDbApp import getInterface rootUser = getRootUser() root_interface = getInterface(rootUser) diff --git a/modules/datamodels/FIELD_NAMES.md b/modules/datamodels/FIELD_NAMES.md deleted file mode 100644 index e75899ed..00000000 --- a/modules/datamodels/FIELD_NAMES.md +++ /dev/null @@ -1,314 +0,0 @@ -| Field Name | Type Pattern | Models Using It | -|------------|--------------|-----------------| -| `accumulatedJsonString` | str | JsonAccumulationState | -| `action` | str | ActionDefinition | -| `actionId` | str | ChatDocument, ChatMessage | -| `actionList` | List | TaskItem | -| `actionMethod` | str | ChatMessage | -| `actionName` | str | ChatMessage | -| `actionNumber` | int | ChatLog, ChatDocument, ChatMessage | -| `actionObjective` | str | ActionDefinition, TaskContext | -| `actionProgress` | str | ChatMessage | -| `actionResult` | Any | TaskResult | -| `active` | bool | AutomationDefinition | -| `additionalData` | Dict | AiResponseMetadata | -| `aiPrompt` | str | AiProcessParameters | -| `allSections` | List | JsonAccumulationState | -| `apiUrl` | str | AiModel | -| `authenticationAuthority` | AuthAuthority | User | -| `authority` | AuthAuthority | UserConnection, Token | -| `availableConnections` | list | TaskContext | -| `availableDocuments` | str | TaskContext | -| `base64Encoded` | bool | ContentMetadata, FileData | -| `bytesSent` | int | ChatStat, AiCallResponse | -| `bytesReceived` | int | ChatStat, AiCallResponse | -| `classes` | List | CodeContentPromptArgs | -| `colorMode` | str | ContentMetadata | -| `compressContext` | bool | AiCallOptions | -| `compressPrompt` | bool | AiCallOptions | -| `condition` | str | SelectionRule | -| `confidence` | float | ReviewResult | -| `connectedAt` | float | UserConnection | -| `connectionId` | str | Token | -| `connectionReference` | str | ActionDefinition | -| `connectorType` | str | AiModel | -| `content` | str | Prompt, ContentItem, AiResponse, AiCallResponse, FilePreview | -| `contentAnalysis` | Dict | Observation | -| `contentParts` | List | AiCallRequest, AiProcessParameters, SectionPromptArgs, ChapterStructurePromptArgs, CodeContentPromptArgs, CodeStructurePromptArgs | -| `contentSize` | str | ObservationPreview | -| `contentValidation` | Dict | Observation | -| `contents` | List | ChatContentExtracted | -| `context` | Dict, AccessRuleContext | TaskHandover, UnderstandingResult, AccessRule | -| `contextInfo` | str | CodeContentPromptArgs | -| `costPer1kTokensInput` | float | AiModel | -| `costPer1kTokensOutput` | float | AiModel | -| `country` | str | AiCallPromptWebSearch | -| `create` | AccessLevel | AccessRule, UserPermissions | -| `created` | str | ObservationPreview | -| `createdAt` | float | Token | -| `creationDate` | float | FileItem | -| `criteriaProgress` | dict | TaskContext | -| `currentAction` | int | ChatWorkflow | -| `currentRound` | int | ChatWorkflow | -| `currentTask` | int | ChatWorkflow | -| `data` | str | ContentItem, FileData | -| `dataType` | str | TaskStep | -| `delete` | AccessLevel | AccessRule, UserPermissions | -| `deliverable` | Dict | TaskDefinition | -| `delivered_summary` | str | ContinuationContext | -| `dependencies` | List | TaskItem, TaskStep, CodeContentPromptArgs | -| `description` | TextMultilingual | Role | -| `details` | str | AuthEvent | -| `detectedComplexity` | str | RequestContext | -| `displayName` | str | AiModel | -| `documentData` | Any | ActionDocument, DocumentData | -| `documentId` | str | ObservationPreview | -| `documentList` | DocumentReferenceList | ActionDefinition, ExtractContentParameters | -| `documentName` | str | ActionDocument, DocumentData | -| `documentReferences` | List | UnderstandingResult | -| `documents` | List | ChatMessage, ActionResult, AiResponse | -| `documentsCount` | int | Observation | -| `documentsLabel` | str | ChatMessage, DocumentExchange | -| `durationSec` | float | ContentMetadata | -| `email` | EmailStr | User | -| `enabled` | bool | Mandate, User | -| `encoding` | str | FilePreview | -| `engine` | str | ChatStat | -| `error` | str | ContentMetadata, ActionResult, ActionItem, TaskItem, TaskResult | -| `errorCount` | int | ChatStat, AiCallResponse | -| `estimatedComplexity` | str | TaskStep | -| `eventId` | str | AutomationDefinition | -| `eventType` | str | AuthEvent | -| `execAction` | str | ActionItem | -| `execMethod` | str | ActionItem | -| `execParameters` | Dict | ActionItem | -| `execResultLabel` | str | ActionItem | -| `executedActions` | list | TaskContext | -| `executionLogs` | List | AutomationDefinition | -| `expectedDocumentFormats` | List | ActionItem | -| `expectedFormats` | List | ChatWorkflow, TaskStep | -| `expectedOutputFormat` | str | RequestContext | -| `expectedOutputType` | str | RequestContext | -| `expiresAt` | float | UserConnection, Token | -| `externalEmail` | EmailStr | UserConnection | -| `externalId` | str | UserConnection | -| `externalUsername` | str | UserConnection | -| `extractionMethod` | str | AiResponseMetadata | -| `extractionOptions` | Any | TaskDefinition, ExtractContentParameters | -| `failedActions` | list | TaskContext | -| `failurePatterns` | list | TaskContext | -| `feedback` | str | TaskResult, TaskItem | -| `fileHash` | str | FileItem | -| `fileId` | str | ChatDocument | -| `fileName` | str | FileItem, FilePreview, ChatDocument | -| `fileSize` | int | FileItem, ChatDocument | -| `fileType` | str | CodeContentPromptArgs | -| `filename` | str | AiResponseMetadata, CodeContentPromptArgs | -| `finishedAt` | float | TaskItem | -| `fps` | float | ContentMetadata | -| `fullName` | str | User | -| `functionCall` | Callable | AiModel | -| `functions` | List | CodeContentPromptArgs | -| `generationHint` | str | SectionPromptArgs | -| `handoverType` | str | TaskHandover | -| `hashedPassword` | str | UserInDB | -| `height` | int | ContentMetadata | -| `hierarchyContext` | str | JsonContinuationContexts | -| `hierarchyContextForPrompt` | str | JsonContinuationContexts | -| `id` | str | *Most models* | -| `improvements` | List | TaskHandover, TaskContext, ReviewResult | -| `incomplete_part` | str | ContinuationContext | -| `inputDocuments` | List | TaskHandover | -| `instruction` | str | AiCallPromptWebSearch, AiCallPromptWebCrawl | -| `intention` | Dict | UnderstandingResult | -| `ipAddress` | str | AuthEvent | -| `isAccumulationMode` | bool | JsonAccumulationState | -| `isAggregation` | bool | SectionPromptArgs | -| `isAvailable` | bool | AiModel | -| `isRegeneration` | bool | TaskContext | -| `isSystemRole` | bool | Role | -| `isText` | bool | FilePreview | -| `item` | str | AccessRule | -| `jsonParsingSuccess` | bool | JsonContinuationContexts | -| `kpis` | List | JsonAccumulationState | -| `label` | str | ContentItem, AutomationDefinition | -| `language` | str | Mandate, User, SectionPromptArgs, AiCallPromptWebSearch | -| `last_complete_part` | str | ContinuationContext | -| `last_raw_json` | str | ContinuationContext | -| `lastActivity` | float | ChatWorkflow | -| `lastChecked` | float | UserConnection | -| `lastParsedResult` | Dict | JsonAccumulationState | -| `lastUpdated` | str | AiModel | -| `learnings` | List | ActionDefinition, TaskContext | -| `listFileId` | List | UserInputRequest | -| `logs` | List | ChatWorkflow | -| `mandateId` | str | ChatWorkflow, FileItem, Prompt, User, AutomationDefinition, Token | -| `maxCost` | float | SelectionRule, AiCallOptions | -| `maxDepth` | int | AiCallPromptWebCrawl | -| `maxNumberPages` | int | AiCallPromptWebSearch | -| `maxParts` | int | AiCallOptions | -| `maxProcessingTime` | int | AiCallOptions | -| `maxSteps` | int | ChatWorkflow | -| `maxTokens` | int | AiModel | -| `maxWidth` | int | AiCallPromptWebCrawl | -| `message` | str | ChatLog, ChatMessage | -| `messageHistory` | List | TaskHandover | -| `messageId` | str | ChatDocument | -| `messages` | List | ChatWorkflow, AiModelCall | -| `metadata` | ContentMetadata, AiResponseMetadata, Dict | ContentItem, AiResponse, AiModelResponse, CodeContentPromptArgs | -| `metCriteria` | List | ReviewResult | -| `method` | str | ActionSelection | -| `mime` | str | ObservationPreview | -| `mimeType` | str | ContentMetadata, FileItem, FilePreview, ChatDocument, ActionDocument, DocumentData | -| `minContextLength` | int | AiModel, SelectionRule | -| `minQualityRating` | int | SelectionRule | -| `missingOutputs` | List | ReviewResult | -| `model` | AiModel | AiModelCall | -| `modelId` | str | AiModelResponse | -| `modelName` | str | AiCallResponse | -| `modified` | str | ObservationPreview | -| `name` | str | Mandate, Prompt, ActionSelection, AiModel, SelectionRule, ObservationPreview | -| `nextAction` | str | ReviewResult | -| `nextActionGuidance` | Dict | TaskContext | -| `nextActionObjective` | str | ReviewResult | -| `nextActionParameters` | Dict | ReviewResult | -| `notes` | List | Observation | -| `objective` | str | TaskStep, TaskDefinition | -| `operationId` | str | ChatLog | -| `operationType` | OperationTypeEnum, str | OperationTypeRating, AiCallOptions, AiResponseMetadata | -| `operationTypes` | List | AiModel, SelectionRule | -| `options` | AiCallOptions | AiCallRequest, AiModelCall | -| `originalPrompt` | str | RequestContext | -| `outputDocuments` | List | TaskHandover | -| `outputFormat` | str | ChapterStructurePromptArgs | -| `overlapContext` | str | JsonContinuationContexts | -| `overlap_context` | str | ContinuationContext | -| `overview` | str | TaskPlan | -| `pages` | int | ContentMetadata | -| `parameters` | Dict | ActionParameters, UnderstandingResult, ActionDefinition | -| `parametersContext` | str | ActionDefinition, TaskContext | -| `parentId` | str | ChatLog | -| `parentMessageId` | str | ChatMessage | -| `performance` | Dict | ChatLog | -| `placeholders` | List, Dict | PromptBundle, AutomationDefinition | -| `priceUsd` | float | ChatStat, AiCallResponse | -| `previews` | List | Observation | -| `previousActionResults` | list | TaskContext | -| `previousHandover` | TaskHandover | TaskContext | -| `previousResults` | List | TaskHandover, TaskContext, ReviewContext | -| `previousReviewResult` | dict | TaskContext | -| `priority` | PriorityEnum | AiModel, SelectionRule, AiCallOptions | -| `process` | str | ChatStat | -| `processDocumentsIndividually` | bool | AiCallOptions | -| `processingMode` | ProcessingModeEnum | AiModel, AiCallOptions | -| `processingTime` | float | ChatStat, ActionItem, TaskItem, AiCallResponse, AiModelResponse | -| `progress` | float | ChatLog | -| `prompt` | str | UserInputRequest, PromptBundle, AiCallPromptImage | -| `publishedAt` | float | ChatMessage | -| `qualityRating` | int | AiModel | -| `qualityRequirements` | Dict | TaskStep | -| `qualityScore` | float | ReviewResult | -| `quality` | str | AiCallPromptImage | -| `rating` | int | OperationTypeRating | -| `read` | AccessLevel | AccessRule, UserPermissions | -| `reason` | str | Token, ReviewResult | -| `reference` | str | ObservationPreview | -| `requiredDocuments` | List | TaskDefinition | -| `requiresAnalysis` | bool | RequestContext | -| `requiresContentGeneration` | bool | TaskDefinition | -| `requiresDocumentAnalysis` | bool | TaskDefinition | -| `requiresDocuments` | bool | RequestContext | -| `requiresWebResearch` | bool | RequestContext, TaskDefinition | -| `researchDepth` | str | AiCallPromptWebSearch | -| `resetToken` | str | UserInDB | -| `resetTokenExpires` | float | UserInDB | -| `result` | str | ActionItem | -| `resultFormat` | str | AiCallOptions | -| `resultLabel` | str | ActionResult, Observation | -| `resultLabels` | Dict | TaskItem | -| `resultType` | str | AiProcessParameters | -| `retryCount` | int | ActionItem, TaskItem, TaskContext | -| `retryMax` | int | ActionItem, TaskItem | -| `revokedAt` | float | Token | -| `revokedBy` | str | Token | -| `role` | str | ChatMessage | -| `roleLabel` | str | Role, AccessRule | -| `roleLabels` | List | User | -| `rollbackOnFailure` | bool | TaskItem | -| `roundNumber` | int | ChatLog, ChatDocument, ChatMessage | -| `safetyMargin` | float | AiCallOptions | -| `schedule` | str | AutomationDefinition | -| `schemaVersion` | str | AiResponseMetadata | -| `section` | Dict | SectionPromptArgs | -| `section_count` | int | ContinuationContext | -| `sectionIndex` | int | SectionPromptArgs | -| `sequenceNr` | int | ChatMessage | -| `sessionId` | str | Token | -| `size` | int, str | ContentMetadata, FilePreview, AiCallPromptImage, ObservationPreview | -| `snippet` | str | ObservationPreview | -| `sourceDocuments` | List | AiResponseMetadata | -| `sourceJson` | Dict | ActionDocument, DocumentData | -| `sourceTask` | str | TaskHandover | -| `speedRating` | int | AiModel | -| `stage1Selection` | dict | TaskContext | -| `startedAt` | float | ChatWorkflow, TaskItem | -| `stats` | List | ChatWorkflow | -| `status` | str, TokenStatus, TaskStatus, ConnectionStatus | ChatLog, ChatWorkflow, ChatMessage, UserConnection, AutomationDefinition, ActionItem, TaskItem, TaskResult, ReviewResult, Token | -| `style` | str | AiCallPromptImage | -| `success` | bool | ChatMessage, ActionResult, Observation, TaskResult, AuthEvent, AiModelResponse | -| `successCriteria` | list | TaskStep | -| `successfulActions` | list | TaskContext | -| `summary` | str | ChatMessage | -| `summaryAllowed` | bool | PromptPlaceholder | -| `taskActions` | list | ReviewContext | -| `taskId` | str | TaskResult, TaskHandover | -| `taskNumber` | int | ChatLog, ChatDocument, ChatMessage | -| `taskProgress` | str | ChatMessage | -| `tasks` | List | ChatWorkflow, TaskPlan, UnderstandingResult | -| `taskStep` | TaskStep | TaskContext, ReviewContext | -| `temperature` | float | AiModel, AiCallOptions | -| `template` | str | AutomationDefinition | -| `template_structure` | str | ContinuationContext | -| `timestamp` | float | ChatLog, ActionItem, TaskHandover, AuthEvent | -| `title` | str | AiResponseMetadata | -| `tokenAccess` | str | Token | -| `tokenExpiresAt` | float | UserConnection | -| `tokenRefresh` | str | Token | -| `tokensUsed` | Dict | AiModelResponse | -| `tokenStatus` | str | UserConnection | -| `tokenType` | str | Token | -| `totalActions` | int | ChatWorkflow | -| `totalTasks` | int | ChatWorkflow | -| `type` | str | ChatLog | -| `typeGroup` | str | ObservationPreview | -| `unmetCriteria` | List | ReviewResult | -| `update` | AccessLevel | AccessRule, UserPermissions | -| `url` | str | AiCallPromptWebCrawl | -| `userAgent` | str | AuthEvent | -| `userId` | str | UserConnection, Token, AuthEvent | -| `userInput` | str | TaskItem | -| `userLanguage` | str | UserInputRequest, RequestContext | -| `userMessage` | str | ActionItem, TaskStep, ReviewResult, TaskPlan, ActionDefinition | -| `userPrompt` | str | SectionPromptArgs, ChapterStructurePromptArgs, CodeContentPromptArgs, CodeStructurePromptArgs | -| `username` | str | User | -| `validationMetadata` | Dict | ActionDocument | -| `version` | str | AiModel | -| `view` | bool | AccessRule, UserPermissions | -| `weight` | float | SelectionRule | -| `width` | int | ContentMetadata | -| `workflow` | ChatWorkflow | TaskContext | -| `workflowId` | str | ChatStat, ChatLog, ChatMessage, ChatWorkflow, TaskItem, TaskContext, ReviewContext | -| `workflowMode` | WorkflowModeEnum | ChatWorkflow | -| `workflowSummary` | str | TaskHandover | - - - ---- - -Can you adapt following fields to Multilingual Fields (`TextMultilingual`): - -| Field Name | Models | -|------------|--------| -| `description` | Role | (is already) - - diff --git a/modules/datamodels/datamodelChatbot.py b/modules/datamodels/datamodelChatbot.py new file mode 100644 index 00000000..45c5c4eb --- /dev/null +++ b/modules/datamodels/datamodelChatbot.py @@ -0,0 +1,1061 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Chat models: ChatWorkflow, ChatMessage, ChatLog, ChatStat, ChatDocument.""" + +from typing import List, Dict, Any, Optional +from enum import Enum +from pydantic import BaseModel, Field +from modules.shared.attributeUtils import registerModelLabels +from modules.shared.timeUtils import getUtcTimestamp +import uuid + + +class ChatStat(BaseModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), description="Primary key" + ) + mandateId: str = Field( + description="ID of the mandate this stat belongs to" + ) + featureInstanceId: str = Field( + description="ID of the feature instance this stat belongs to" + ) + workflowId: Optional[str] = Field( + None, description="Foreign key to workflow (for workflow stats)" + ) + processingTime: Optional[float] = Field( + None, description="Processing time in seconds" + ) + bytesSent: Optional[int] = Field(None, description="Number of bytes sent") + bytesReceived: Optional[int] = Field(None, description="Number of bytes received") + errorCount: Optional[int] = Field(None, description="Number of errors encountered") + process: Optional[str] = Field(None, description="The process that delivers the stats data (e.g. 'action.outlook.readMails', 'ai.process.document.name')") + engine: Optional[str] = Field(None, description="The engine used (e.g. 'ai.anthropic.35', 'ai.tavily.basic', 'renderer.docx')") + priceUsd: Optional[float] = Field(None, description="Calculated price in USD for the operation") + + +registerModelLabels( + "ChatStat", + {"en": "Chat Statistics", "fr": "Statistiques de chat"}, + { + "id": {"en": "ID", "fr": "ID"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, + "workflowId": {"en": "Workflow ID", "fr": "ID du workflow"}, + "processingTime": {"en": "Processing Time", "fr": "Temps de traitement"}, + "bytesSent": {"en": "Bytes Sent", "fr": "Octets envoyés"}, + "bytesReceived": {"en": "Bytes Received", "fr": "Octets reçus"}, + "errorCount": {"en": "Error Count", "fr": "Nombre d'erreurs"}, + "process": {"en": "Process", "fr": "Processus"}, + "engine": {"en": "Engine", "fr": "Moteur"}, + "priceUsd": {"en": "Price USD", "fr": "Prix USD"}, + }, +) + + +class ChatLog(BaseModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), description="Primary key" + ) + mandateId: str = Field( + description="ID of the mandate this log belongs to" + ) + featureInstanceId: str = Field( + description="ID of the feature instance this log belongs to" + ) + workflowId: str = Field(description="Foreign key to workflow") + message: str = Field(description="Log message") + type: str = Field(description="Log type (info, warning, error, etc.)") + timestamp: float = Field( + default_factory=getUtcTimestamp, + description="When the log entry was created (UTC timestamp in seconds)", + ) + status: Optional[str] = Field(None, description="Status of the log entry") + progress: Optional[float] = Field( + None, description="Progress indicator (0.0 to 1.0)" + ) + performance: Optional[Dict[str, Any]] = Field( + None, description="Performance metrics" + ) + parentId: Optional[str] = Field( + None, description="Parent operation ID (operationId of parent operation) for hierarchical display" + ) + operationId: Optional[str] = Field( + None, description="Operation ID to group related log entries" + ) + roundNumber: Optional[int] = Field(None, description="Round number in workflow") + taskNumber: Optional[int] = Field(None, description="Task number within round") + actionNumber: Optional[int] = Field(None, description="Action number within task") + + +registerModelLabels( + "ChatLog", + {"en": "Chat Log", "fr": "Journal de chat"}, + { + "id": {"en": "ID", "fr": "ID"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, + "workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"}, + "message": {"en": "Message", "fr": "Message"}, + "type": {"en": "Type", "fr": "Type"}, + "timestamp": {"en": "Timestamp", "fr": "Horodatage"}, + "status": {"en": "Status", "fr": "Statut"}, + "progress": {"en": "Progress", "fr": "Progression"}, + "performance": {"en": "Performance", "fr": "Performance"}, + }, +) + + +class ChatDocument(BaseModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), description="Primary key" + ) + mandateId: str = Field( + description="ID of the mandate this document belongs to" + ) + featureInstanceId: str = Field( + description="ID of the feature instance this document belongs to" + ) + messageId: str = Field(description="Foreign key to message") + fileId: str = Field(description="Foreign key to file") + fileName: str = Field(description="Name of the file") + fileSize: int = Field(description="Size of the file") + mimeType: str = Field(description="MIME type of the file") + roundNumber: Optional[int] = Field(None, description="Round number in workflow") + taskNumber: Optional[int] = Field(None, description="Task number within round") + actionNumber: Optional[int] = Field(None, description="Action number within task") + actionId: Optional[str] = Field( + None, description="ID of the action that created this document" + ) + + +registerModelLabels( + "ChatDocument", + {"en": "Chat Document", "fr": "Document de chat"}, + { + "id": {"en": "ID", "fr": "ID"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, + "messageId": {"en": "Message ID", "fr": "ID du message"}, + "fileId": {"en": "File ID", "fr": "ID du fichier"}, + "fileName": {"en": "File Name", "fr": "Nom du fichier"}, + "fileSize": {"en": "File Size", "fr": "Taille du fichier"}, + "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, + "roundNumber": {"en": "Round Number", "fr": "Numéro de tour"}, + "taskNumber": {"en": "Task Number", "fr": "Numéro de tâche"}, + "actionNumber": {"en": "Action Number", "fr": "Numéro d'action"}, + "actionId": {"en": "Action ID", "fr": "ID de l'action"}, + }, +) + + +class ContentMetadata(BaseModel): + size: int = Field(description="Content size in bytes") + pages: Optional[int] = Field( + None, description="Number of pages for multi-page content" + ) + error: Optional[str] = Field(None, description="Processing error if any") + width: Optional[int] = Field(None, description="Width in pixels for images/videos") + height: Optional[int] = Field( + None, description="Height in pixels for images/videos" + ) + colorMode: Optional[str] = Field(None, description="Color mode") + fps: Optional[float] = Field(None, description="Frames per second for videos") + durationSec: Optional[float] = Field( + None, description="Duration in seconds for media" + ) + mimeType: str = Field(description="MIME type of the content") + base64Encoded: bool = Field(description="Whether the data is base64 encoded") + + +registerModelLabels( + "ContentMetadata", + {"en": "Content Metadata", "fr": "Métadonnées du contenu"}, + { + "size": {"en": "Size", "fr": "Taille"}, + "pages": {"en": "Pages", "fr": "Pages"}, + "error": {"en": "Error", "fr": "Erreur"}, + "width": {"en": "Width", "fr": "Largeur"}, + "height": {"en": "Height", "fr": "Hauteur"}, + "colorMode": {"en": "Color Mode", "fr": "Mode de couleur"}, + "fps": {"en": "FPS", "fr": "IPS"}, + "durationSec": {"en": "Duration", "fr": "Durée"}, + "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, + "base64Encoded": {"en": "Base64 Encoded", "fr": "Encodé en Base64"}, + }, +) + + +class ContentItem(BaseModel): + label: str = Field(description="Content label") + data: str = Field(description="Extracted text content") + metadata: ContentMetadata = Field(description="Content metadata") + + +registerModelLabels( + "ContentItem", + {"en": "Content Item", "fr": "Élément de contenu"}, + { + "label": {"en": "Label", "fr": "Étiquette"}, + "data": {"en": "Data", "fr": "Données"}, + "metadata": {"en": "Metadata", "fr": "Métadonnées"}, + }, +) + + +class ChatContentExtracted(BaseModel): + id: str = Field(description="Reference to source ChatDocument") + contents: List[ContentItem] = Field( + default_factory=list, description="List of content items" + ) + + +registerModelLabels( + "ChatContentExtracted", + {"en": "Extracted Content", "fr": "Contenu extrait"}, + { + "id": {"en": "Object ID", "fr": "ID de l'objet"}, + "contents": {"en": "Contents", "fr": "Contenus"}, + }, +) + + +class ChatMessage(BaseModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), description="Primary key" + ) + mandateId: str = Field( + description="ID of the mandate this message belongs to" + ) + featureInstanceId: str = Field( + description="ID of the feature instance this message belongs to" + ) + workflowId: str = Field(description="Foreign key to workflow") + parentMessageId: Optional[str] = Field( + None, description="Parent message ID for threading" + ) + documents: List[ChatDocument] = Field( + default_factory=list, description="Associated documents" + ) + documentsLabel: Optional[str] = Field( + None, description="Label for the set of documents" + ) + message: Optional[str] = Field(None, description="Message content") + summary: Optional[str] = Field( + None, description="Short summary of this message for planning/history" + ) + role: str = Field(description="Role of the message sender") + status: str = Field(description="Status of the message (first, step, last)") + sequenceNr: int = Field( + description="Sequence number of the message (set automatically)" + ) + publishedAt: float = Field( + default_factory=getUtcTimestamp, + description="When the message was published (UTC timestamp in seconds)", + ) + success: Optional[bool] = Field( + None, description="Whether the message processing was successful" + ) + actionId: Optional[str] = Field( + None, description="ID of the action that produced this message" + ) + actionMethod: Optional[str] = Field( + None, description="Method of the action that produced this message" + ) + actionName: Optional[str] = Field( + None, description="Name of the action that produced this message" + ) + roundNumber: Optional[int] = Field(None, description="Round number in workflow") + taskNumber: Optional[int] = Field(None, description="Task number within round") + actionNumber: Optional[int] = Field(None, description="Action number within task") + taskProgress: Optional[str] = Field( + None, description="Task progress status: pending, running, success, fail, retry" + ) + actionProgress: Optional[str] = Field( + None, description="Action progress status: pending, running, success, fail" + ) + + +registerModelLabels( + "ChatMessage", + {"en": "Chat Message", "fr": "Message de chat"}, + { + "id": {"en": "ID", "fr": "ID"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, + "workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"}, + "parentMessageId": {"en": "Parent Message ID", "fr": "ID du message parent"}, + "documents": {"en": "Documents", "fr": "Documents"}, + "documentsLabel": {"en": "Documents Label", "fr": "Label des documents"}, + "message": {"en": "Message", "fr": "Message"}, + "summary": {"en": "Summary", "fr": "Résumé"}, + "role": {"en": "Role", "fr": "Rôle"}, + "status": {"en": "Status", "fr": "Statut"}, + "sequenceNr": {"en": "Sequence Number", "fr": "Numéro de séquence"}, + "publishedAt": {"en": "Published At", "fr": "Publié le"}, + "success": {"en": "Success", "fr": "Succès"}, + "actionId": {"en": "Action ID", "fr": "ID de l'action"}, + "actionMethod": {"en": "Action Method", "fr": "Méthode de l'action"}, + "actionName": {"en": "Action Name", "fr": "Nom de l'action"}, + "roundNumber": {"en": "Round Number", "fr": "Numéro de tour"}, + "taskNumber": {"en": "Task Number", "fr": "Numéro de tâche"}, + "actionNumber": {"en": "Action Number", "fr": "Numéro d'action"}, + "taskProgress": {"en": "Task Progress", "fr": "Progression de la tâche"}, + "actionProgress": {"en": "Action Progress", "fr": "Progression de l'action"}, + }, +) + + +class WorkflowModeEnum(str, Enum): + WORKFLOW_DYNAMIC = "Dynamic" + WORKFLOW_AUTOMATION = "Automation" + WORKFLOW_CHATBOT = "Chatbot" + WORKFLOW_REACT = "React" # Legacy mode - kept for backward compatibility + + +registerModelLabels( + "WorkflowModeEnum", + {"en": "Workflow Mode", "fr": "Mode de workflow"}, + { + "WORKFLOW_DYNAMIC": {"en": "Dynamic", "fr": "Dynamique"}, + "WORKFLOW_AUTOMATION": {"en": "Automation", "fr": "Automatisation"}, + "WORKFLOW_CHATBOT": {"en": "Chatbot", "fr": "Chatbot"}, + "WORKFLOW_REACT": {"en": "React (Legacy)", "fr": "React (Hérité)"}, + }, +) + + +class ChatWorkflow(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + mandateId: str = Field(description="ID of the mandate this workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + featureInstanceId: str = Field(description="ID of the feature instance this workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + {"value": "running", "label": {"en": "Running", "fr": "En cours"}}, + {"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}}, + {"value": "stopped", "label": {"en": "Stopped", "fr": "Arrêté"}}, + {"value": "error", "label": {"en": "Error", "fr": "Erreur"}}, + ]}) + name: Optional[str] = Field(None, description="Name of the workflow", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) + currentRound: int = Field(default=0, description="Current round number", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + currentTask: int = Field(default=0, description="Current task number", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + currentAction: int = Field(default=0, description="Current action number", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + totalTasks: int = Field(default=0, description="Total number of tasks in the workflow", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + totalActions: int = Field(default=0, description="Total number of actions in the workflow", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + lastActivity: float = Field(default_factory=getUtcTimestamp, description="Timestamp of last activity (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) + startedAt: float = Field(default_factory=getUtcTimestamp, description="When the workflow started (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) + logs: List[ChatLog] = Field(default_factory=list, description="Workflow logs", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + messages: List[ChatMessage] = Field(default_factory=list, description="Messages in the workflow", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + stats: List[ChatStat] = Field(default_factory=list, description="Workflow statistics list", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + tasks: list = Field(default_factory=list, description="List of tasks in the workflow", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + workflowMode: WorkflowModeEnum = Field(default=WorkflowModeEnum.WORKFLOW_DYNAMIC, description="Workflow mode selector", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + { + "value": WorkflowModeEnum.WORKFLOW_DYNAMIC.value, + "label": {"en": "Dynamic", "fr": "Dynamique"}, + }, + { + "value": WorkflowModeEnum.WORKFLOW_AUTOMATION.value, + "label": {"en": "Automation", "fr": "Automatisation"}, + }, + { + "value": WorkflowModeEnum.WORKFLOW_CHATBOT.value, + "label": {"en": "Chatbot", "fr": "Chatbot"}, + }, + { + "value": WorkflowModeEnum.WORKFLOW_REACT.value, + "label": {"en": "React (Legacy)", "fr": "React (Hérité)"}, + }, + ]}) + maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"frontend_type": "integer", "frontend_readonly": False, "frontend_required": False}) + expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + + # Helper methods for execution state management + def getRoundIndex(self) -> int: + """Get current round index""" + return self.currentRound + + def getTaskIndex(self) -> int: + """Get current task index""" + return self.currentTask + + def getActionIndex(self) -> int: + """Get current action index""" + return self.currentAction + + def incrementRound(self): + """Increment round when new user input received""" + self.currentRound += 1 + self.currentTask = 0 + self.currentAction = 0 + + def incrementTask(self): + """Increment task when starting new task in current round""" + self.currentTask += 1 + self.currentAction = 0 + + def incrementAction(self): + """Increment action when executing new action in current task""" + self.currentAction += 1 + + +registerModelLabels( + "ChatWorkflow", + {"en": "Chat Workflow", "fr": "Flux de travail de chat"}, + { + "id": {"en": "ID", "fr": "ID"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, + "status": {"en": "Status", "fr": "Statut"}, + "name": {"en": "Name", "fr": "Nom"}, + "currentRound": {"en": "Current Round", "fr": "Tour actuel"}, + "currentTask": {"en": "Current Task", "fr": "Tâche actuelle"}, + "currentAction": {"en": "Current Action", "fr": "Action actuelle"}, + "totalTasks": {"en": "Total Tasks", "fr": "Total des tâches"}, + "totalActions": {"en": "Total Actions", "fr": "Total des actions"}, + "lastActivity": {"en": "Last Activity", "fr": "Dernière activité"}, + "startedAt": {"en": "Started At", "fr": "Démarré le"}, + "logs": {"en": "Logs", "fr": "Journaux"}, + "messages": {"en": "Messages", "fr": "Messages"}, + "stats": {"en": "Statistics", "fr": "Statistiques"}, + "tasks": {"en": "Tasks", "fr": "Tâches"}, + "workflowMode": {"en": "Workflow Mode", "fr": "Mode de workflow"}, + "maxSteps": {"en": "Max Steps", "fr": "Étapes max"}, + "expectedFormats": {"en": "Expected Formats", "fr": "Formats attendus"}, + }, +) + + +class UserInputRequest(BaseModel): + prompt: str = Field(description="Prompt for the user") + listFileId: List[str] = Field(default_factory=list, description="List of file IDs") + userLanguage: str = Field(default="en", description="User's preferred language") + workflowId: Optional[str] = Field(None, description="Optional ID of the workflow to continue") + + +registerModelLabels( + "UserInputRequest", + {"en": "User Input Request", "fr": "Demande de saisie utilisateur"}, + { + "prompt": {"en": "Prompt", "fr": "Invite"}, + "listFileId": {"en": "File IDs", "fr": "IDs des fichiers"}, + "userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"}, + }, +) + + +class ActionDocument(BaseModel): + """Clear document structure for action results""" + + documentName: str = Field(description="Name of the document") + documentData: Any = Field(description="Content/data of the document") + mimeType: str = Field(description="MIME type of the document") + sourceJson: Optional[Dict[str, Any]] = Field( + None, + description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)" + ) + validationMetadata: Optional[Dict[str, Any]] = Field( + None, + description="Action-specific metadata for content validation (e.g., email recipients, attachments, SharePoint paths)" + ) + + +registerModelLabels( + "ActionDocument", + {"en": "Action Document", "fr": "Document d'action"}, + { + "documentName": {"en": "Document Name", "fr": "Nom du document"}, + "documentData": {"en": "Document Data", "fr": "Données du document"}, + "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, + }, +) + + +class ActionResult(BaseModel): + """Clean action result with documents as primary output + + IMPORTANT: Action methods should NOT set resultLabel in their return value. + The resultLabel is managed by the action handler using the action's execResultLabel + from the action plan. This ensures consistent document routing throughout the workflow. + """ + + success: bool = Field(description="Whether execution succeeded") + error: Optional[str] = Field(None, description="Error message if failed") + documents: List[ActionDocument] = Field( + default_factory=list, description="Document outputs" + ) + resultLabel: Optional[str] = Field( + None, + description="Label for document routing (set by action handler, not by action methods)", + ) + + @classmethod + def isSuccess(cls, documents: List[ActionDocument] = None) -> "ActionResult": + return cls(success=True, documents=documents or []) + + @classmethod + def isFailure( + cls, error: str, documents: List[ActionDocument] = None + ) -> "ActionResult": + return cls(success=False, documents=documents or [], error=error) + + +registerModelLabels( + "ActionResult", + {"en": "Action Result", "fr": "Résultat de l'action"}, + { + "success": {"en": "Success", "fr": "Succès"}, + "error": {"en": "Error", "fr": "Erreur"}, + "documents": {"en": "Documents", "fr": "Documents"}, + "resultLabel": {"en": "Result Label", "fr": "Étiquette du résultat"}, + }, +) + + +class ActionSelection(BaseModel): + method: str = Field(description="Method to execute (e.g., web, document, ai)") + name: str = Field( + description="Action name within the method (e.g., search, extract)" + ) + + +registerModelLabels( + "ActionSelection", + {"en": "Action Selection", "fr": "Sélection d'action"}, + { + "method": {"en": "Method", "fr": "Méthode"}, + "name": {"en": "Action Name", "fr": "Nom de l'action"}, + }, +) + + +class ActionParameters(BaseModel): + parameters: Dict[str, Any] = Field( + default_factory=dict, description="Parameters to execute the selected action" + ) + + +registerModelLabels( + "ActionParameters", + {"en": "Action Parameters", "fr": "Paramètres d'action"}, + { + "parameters": {"en": "Parameters", "fr": "Paramètres"}, + }, +) + + +class ObservationPreview(BaseModel): + name: str = Field(description="Document name or URL label") + mime: Optional[str] = Field(default=None, description="MIME type or kind (legacy field)") + snippet: Optional[str] = Field(default=None, description="Short snippet or summary") + # Extended metadata fields + mimeType: Optional[str] = Field(default=None, description="MIME type") + size: Optional[str] = Field(default=None, description="File size") + created: Optional[str] = Field(default=None, description="Creation timestamp") + modified: Optional[str] = Field(default=None, description="Modification timestamp") + typeGroup: Optional[str] = Field(default=None, description="Document type group") + documentId: Optional[str] = Field(default=None, description="Document ID") + reference: Optional[str] = Field(default=None, description="Document reference") + contentSize: Optional[str] = Field(default=None, description="Content size indicator") + + +registerModelLabels( + "ObservationPreview", + {"en": "Observation Preview", "fr": "Aperçu d'observation"}, + { + "name": {"en": "Name", "fr": "Nom"}, + "mime": {"en": "MIME", "fr": "MIME"}, + "snippet": {"en": "Snippet", "fr": "Extrait"}, + }, +) + + +class Observation(BaseModel): + success: bool = Field(description="Action execution success flag") + resultLabel: str = Field(description="Deterministic label for produced documents") + documentsCount: int = Field(description="Number of produced documents") + previews: List[ObservationPreview] = Field( + default_factory=list, description="Compact previews of outputs" + ) + notes: List[str] = Field( + default_factory=list, description="Short notes or key facts" + ) + # Extended fields for enhanced validation + contentValidation: Optional[Dict[str, Any]] = Field( + default=None, description="Content validation results" + ) + contentAnalysis: Optional[Dict[str, Any]] = Field( + default=None, description="Content analysis results" + ) + + +registerModelLabels( + "Observation", + {"en": "Observation", "fr": "Observation"}, + { + "success": {"en": "Success", "fr": "Succès"}, + "resultLabel": {"en": "Result Label", "fr": "Étiquette du résultat"}, + "documentsCount": {"en": "Documents Count", "fr": "Nombre de documents"}, + "previews": {"en": "Previews", "fr": "Aperçus"}, + "notes": {"en": "Notes", "fr": "Notes"}, + }, +) + + +class TaskStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +registerModelLabels( + "TaskStatus", + {"en": "Task Status", "fr": "Statut de la tâche"}, + { + "PENDING": {"en": "Pending", "fr": "En attente"}, + "RUNNING": {"en": "Running", "fr": "En cours"}, + "COMPLETED": {"en": "Completed", "fr": "Terminé"}, + "FAILED": {"en": "Failed", "fr": "Échec"}, + "CANCELLED": {"en": "Cancelled", "fr": "Annulé"}, + }, +) + + +class DocumentExchange(BaseModel): + documentsLabel: str = Field(description="Label for the set of documents") + documents: List[str] = Field( + default_factory=list, description="List of document references" + ) + + +registerModelLabels( + "DocumentExchange", + {"en": "Document Exchange", "fr": "Échange de documents"}, + { + "documentsLabel": {"en": "Documents Label", "fr": "Label des documents"}, + "documents": {"en": "Documents", "fr": "Documents"}, + }, +) + + +class ActionItem(BaseModel): + id: str = Field(..., description="Action ID") + execMethod: str = Field(..., description="Method to execute") + execAction: str = Field(..., description="Action to perform") + execParameters: Dict[str, Any] = Field( + default_factory=dict, description="Action parameters" + ) + execResultLabel: Optional[str] = Field( + None, description="Label for the set of result documents" + ) + expectedDocumentFormats: Optional[List[Dict[str, str]]] = Field( + None, description="Expected document formats (optional)" + ) + userMessage: Optional[str] = Field( + None, description="User-friendly message in user's language" + ) + status: TaskStatus = Field(default=TaskStatus.PENDING, description="Action status") + error: Optional[str] = Field(None, description="Error message if action failed") + retryCount: int = Field(default=0, description="Number of retries attempted") + retryMax: int = Field(default=3, description="Maximum number of retries") + processingTime: Optional[float] = Field( + None, description="Processing time in seconds" + ) + timestamp: float = Field( + ..., description="When the action was executed (UTC timestamp in seconds)" + ) + result: Optional[str] = Field(None, description="Result of the action") + + def setSuccess(self, result: str = None) -> None: + """Set the action as successful with optional result""" + self.status = TaskStatus.COMPLETED + self.error = None + if result is not None: + self.result = result + + def setError(self, error_message: str) -> None: + """Set the action as failed with error message""" + self.status = TaskStatus.FAILED + self.error = error_message + + +registerModelLabels( + "ActionItem", + {"en": "Task Action", "fr": "Action de tâche"}, + { + "id": {"en": "Action ID", "fr": "ID de l'action"}, + "execMethod": {"en": "Method", "fr": "Méthode"}, + "execAction": {"en": "Action", "fr": "Action"}, + "execParameters": {"en": "Parameters", "fr": "Paramètres"}, + "execResultLabel": {"en": "Result Label", "fr": "Label du résultat"}, + "expectedDocumentFormats": { + "en": "Expected Document Formats", + "fr": "Formats de documents attendus", + }, + "userMessage": {"en": "User Message", "fr": "Message utilisateur"}, + "status": {"en": "Status", "fr": "Statut"}, + "error": {"en": "Error", "fr": "Erreur"}, + "retryCount": {"en": "Retry Count", "fr": "Nombre de tentatives"}, + "retryMax": {"en": "Max Retries", "fr": "Tentatives max"}, + "processingTime": {"en": "Processing Time", "fr": "Temps de traitement"}, + "timestamp": {"en": "Timestamp", "fr": "Horodatage"}, + "result": {"en": "Result", "fr": "Résultat"}, + }, +) + + +class TaskResult(BaseModel): + taskId: str = Field(..., description="Task ID") + status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status") + success: bool = Field(..., description="Whether the task was successful") + feedback: Optional[str] = Field(None, description="Task feedback message") + error: Optional[str] = Field(None, description="Error message if task failed") + + +registerModelLabels( + "TaskResult", + {"en": "Task Result", "fr": "Résultat de tâche"}, + { + "taskId": {"en": "Task ID", "fr": "ID de la tâche"}, + "status": {"en": "Status", "fr": "Statut"}, + "success": {"en": "Success", "fr": "Succès"}, + "feedback": {"en": "Feedback", "fr": "Retour"}, + "error": {"en": "Error", "fr": "Erreur"}, + }, +) + + +class TaskItem(BaseModel): + id: str = Field(..., description="Task ID") + workflowId: str = Field(..., description="Workflow ID") + userInput: str = Field(..., description="User input that triggered the task") + status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status") + error: Optional[str] = Field(None, description="Error message if task failed") + startedAt: Optional[float] = Field( + None, description="When the task started (UTC timestamp in seconds)" + ) + finishedAt: Optional[float] = Field( + None, description="When the task finished (UTC timestamp in seconds)" + ) + actionList: List[ActionItem] = Field( + default_factory=list, description="List of actions to execute" + ) + retryCount: int = Field(default=0, description="Number of retries attempted") + retryMax: int = Field(default=3, description="Maximum number of retries") + rollbackOnFailure: bool = Field( + default=True, description="Whether to rollback on failure" + ) + dependencies: List[str] = Field( + default_factory=list, description="List of task IDs this task depends on" + ) + feedback: Optional[str] = Field(None, description="Task feedback message") + processingTime: Optional[float] = Field( + None, description="Total processing time in seconds" + ) + resultLabels: Optional[Dict[str, Any]] = Field( + default_factory=dict, description="Map of result labels to their values" + ) + + +registerModelLabels( + "TaskItem", + {"en": "Task", "fr": "Tâche"}, + { + "id": {"en": "Task ID", "fr": "ID de la tâche"}, + "workflowId": {"en": "Workflow ID", "fr": "ID du workflow"}, + "userInput": {"en": "User Input", "fr": "Entrée utilisateur"}, + "status": {"en": "Status", "fr": "Statut"}, + "error": {"en": "Error", "fr": "Erreur"}, + "startedAt": {"en": "Started At", "fr": "Démarré à"}, + "finishedAt": {"en": "Finished At", "fr": "Terminé à"}, + "actionList": {"en": "Actions", "fr": "Actions"}, + "retryCount": {"en": "Retry Count", "fr": "Nombre de tentatives"}, + "retryMax": {"en": "Max Retries", "fr": "Tentatives max"}, + "processingTime": {"en": "Processing Time", "fr": "Temps de traitement"}, + }, +) + + +class TaskStep(BaseModel): + id: str + objective: str + dependencies: Optional[list[str]] = Field(default_factory=list) + successCriteria: Optional[list[str]] = Field(default_factory=list) + estimatedComplexity: Optional[str] = None + userMessage: Optional[str] = Field( + None, description="User-friendly message in user's language" + ) + # Format details extracted from intent analysis + dataType: Optional[str] = Field( + None, description="Expected data type (text, numbers, documents, etc.)" + ) + expectedFormats: Optional[List[str]] = Field( + None, description="Expected output file format extensions (e.g., ['docx', 'pdf', 'xlsx']). Use actual file extensions, not conceptual terms." + ) + qualityRequirements: Optional[Dict[str, Any]] = Field( + None, description="Quality requirements and constraints" + ) + + +registerModelLabels( + "TaskStep", + {"en": "Task Step", "fr": "Étape de tâche"}, + { + "id": {"en": "ID", "fr": "ID"}, + "objective": {"en": "Objective", "fr": "Objectif"}, + "dependencies": {"en": "Dependencies", "fr": "Dépendances"}, + "successCriteria": {"en": "Success Criteria", "fr": "Critères de succès"}, + "estimatedComplexity": { + "en": "Estimated Complexity", + "fr": "Complexité estimée", + }, + "userMessage": {"en": "User Message", "fr": "Message utilisateur"}, + "expectedFormats": {"en": "Expected Formats", "fr": "Formats attendus"}, + }, +) + + +class TaskHandover(BaseModel): + taskId: str = Field(description="Target task ID") + sourceTask: Optional[str] = Field(None, description="Source task ID") + inputDocuments: List[DocumentExchange] = Field( + default_factory=list, description="Available input documents" + ) + outputDocuments: List[DocumentExchange] = Field( + default_factory=list, description="Produced output documents" + ) + context: Dict[str, Any] = Field(default_factory=dict, description="Task context") + previousResults: List[str] = Field( + default_factory=list, description="Previous result summaries" + ) + improvements: List[str] = Field( + default_factory=list, description="Improvement suggestions" + ) + workflowSummary: Optional[str] = Field( + None, description="Summarized workflow context" + ) + messageHistory: List[str] = Field( + default_factory=list, description="Key message summaries" + ) + timestamp: float = Field( + ..., description="When the handover was created (UTC timestamp in seconds)" + ) + handoverType: str = Field( + default="task", description="Type of handover: task, phase, or workflow" + ) + + +registerModelLabels( + "TaskHandover", + {"en": "Task Handover", "fr": "Transfert de tâche"}, + { + "taskId": {"en": "Task ID", "fr": "ID de la tâche"}, + "sourceTask": {"en": "Source Task", "fr": "Tâche source"}, + "inputDocuments": {"en": "Input Documents", "fr": "Documents d'entrée"}, + "outputDocuments": {"en": "Output Documents", "fr": "Documents de sortie"}, + "context": {"en": "Context", "fr": "Contexte"}, + "previousResults": {"en": "Previous Results", "fr": "Résultats précédents"}, + "improvements": {"en": "Improvements", "fr": "Améliorations"}, + "workflowSummary": {"en": "Workflow Summary", "fr": "Résumé du workflow"}, + "messageHistory": {"en": "Message History", "fr": "Historique des messages"}, + "timestamp": {"en": "Timestamp", "fr": "Horodatage"}, + "handoverType": {"en": "Handover Type", "fr": "Type de transfert"}, + }, +) + + +class TaskContext(BaseModel): + taskStep: TaskStep + workflow: Optional[ChatWorkflow] = None + workflowId: Optional[str] = None + availableDocuments: Optional[str] = "No documents available" + availableConnections: Optional[list[str]] = Field(default_factory=list) + previousResults: Optional[list[str]] = Field(default_factory=list) + previousHandover: Optional[TaskHandover] = None + improvements: Optional[list[str]] = Field(default_factory=list) + retryCount: Optional[int] = 0 + previousActionResults: Optional[list] = Field(default_factory=list) + previousReviewResult: Optional[dict] = None + isRegeneration: Optional[bool] = False + failurePatterns: Optional[list[str]] = Field(default_factory=list) + failedActions: Optional[list] = Field(default_factory=list) + successfulActions: Optional[list] = Field(default_factory=list) + executedActions: Optional[list] = Field(default_factory=list, description="List of executed actions with action name, parameters, and step number") + criteriaProgress: Optional[dict] = None + + # Stage 2 context fields (NEW) + actionObjective: Optional[str] = Field(None, description="Objective for current action") + parametersContext: Optional[str] = Field(None, description="Context for parameter generation") + learnings: Optional[list[str]] = Field(default_factory=list, description="Learnings from previous actions") + stage1Selection: Optional[dict] = Field(None, description="Stage 1 selection data") + nextActionGuidance: Optional[Dict[str, Any]] = Field(None, description="Guidance for the next action from previous refinement") + + def updateFromSelection(self, selection: Any): + """Update context from Stage 1 selection + + Args: + selection: ActionDefinition instance from Stage 1 + """ + from modules.datamodels.datamodelWorkflow import ActionDefinition + + if isinstance(selection, ActionDefinition): + self.actionObjective = selection.actionObjective + self.parametersContext = selection.parametersContext + self.learnings = selection.learnings if selection.learnings else [] + self.stage1Selection = selection.model_dump() + + def getDocumentReferences(self) -> List[str]: + docs = [] + if self.previousHandover: + for doc_exchange in self.previousHandover.inputDocuments: + docs.extend(doc_exchange.documents) + return list(set(docs)) + + def addImprovement(self, improvement: str) -> None: + if improvement not in (self.improvements or []): + if self.improvements is None: + self.improvements = [] + self.improvements.append(improvement) + + +class ReviewContext(BaseModel): + taskStep: TaskStep + taskActions: Optional[list] = Field(default_factory=list) + actionResults: Optional[list] = Field(default_factory=list) + stepResult: Optional[dict] = Field(default_factory=dict) + workflowId: Optional[str] = None + previousResults: Optional[list[str]] = Field(default_factory=list) + + +class ReviewResult(BaseModel): + status: str + reason: Optional[str] = None + improvements: Optional[list[str]] = Field(default_factory=list) + qualityScore: Optional[float] = Field(default=5.0, description="Quality score (0-10)") + missingOutputs: Optional[list[str]] = Field(default_factory=list) + metCriteria: Optional[list[str]] = Field(default_factory=list) + unmetCriteria: Optional[list[str]] = Field(default_factory=list) + confidence: Optional[float] = 0.5 + userMessage: Optional[str] = Field( + None, description="User-friendly message in user's language" + ) + # NEW: Concrete next action guidance (when status is "continue") + nextAction: Optional[str] = Field( + None, description="Specific action to execute next (e.g., 'ai.convert', 'ai.process', 'ai.reformat')" + ) + nextActionParameters: Optional[Dict[str, Any]] = Field( + None, description="Parameters for the next action (e.g., {'fromFormat': 'json', 'toFormat': 'csv'})" + ) + nextActionObjective: Optional[str] = Field( + None, description="What this specific action will achieve" + ) + + +registerModelLabels( + "ReviewResult", + {"en": "Review Result", "fr": "Résultat de l'évaluation"}, + { + "status": {"en": "Status", "fr": "Statut"}, + "reason": {"en": "Reason", "fr": "Raison"}, + "improvements": {"en": "Improvements", "fr": "Améliorations"}, + "qualityScore": {"en": "Quality Score", "fr": "Score de qualité"}, + "missingOutputs": {"en": "Missing Outputs", "fr": "Sorties manquantes"}, + "metCriteria": {"en": "Met Criteria", "fr": "Critères respectés"}, + "unmetCriteria": {"en": "Unmet Criteria", "fr": "Critères non respectés"}, + "confidence": {"en": "Confidence", "fr": "Confiance"}, + "userMessage": {"en": "User Message", "fr": "Message utilisateur"}, + }, +) + + +class TaskPlan(BaseModel): + overview: str + tasks: list[TaskStep] + userMessage: Optional[str] = Field( + None, description="Overall user-friendly message for the task plan" + ) + + +registerModelLabels( + "TaskPlan", + {"en": "Task Plan", "fr": "Plan de tâches"}, + { + "overview": {"en": "Overview", "fr": "Aperçu"}, + "tasks": {"en": "Tasks", "fr": "Tâches"}, + "userMessage": {"en": "User Message", "fr": "Message utilisateur"}, + }, +) + +# Forward references resolved automatically since ChatWorkflow is defined above + + +class PromptPlaceholder(BaseModel): + label: str + content: str + summaryAllowed: bool = Field( + default=False, + description="Whether host may summarize content before sending to AI", + ) + + +registerModelLabels( + "PromptPlaceholder", + {"en": "Prompt Placeholder", "fr": "Espace réservé d'invite"}, + { + "label": {"en": "Label", "fr": "Libellé"}, + "content": {"en": "Content", "fr": "Contenu"}, + "summaryAllowed": {"en": "Summary Allowed", "fr": "Résumé autorisé"}, + }, +) + + +class PromptBundle(BaseModel): + prompt: str + placeholders: List[PromptPlaceholder] = Field(default_factory=list) + + +registerModelLabels( + "PromptBundle", + {"en": "Prompt Bundle", "fr": "Lot d'invite"}, + { + "prompt": {"en": "Prompt", "fr": "Invite"}, + "placeholders": {"en": "Placeholders", "fr": "Espaces réservés"}, + }, +) + + +class AutomationDefinition(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + featureInstanceId: str = Field(description="ID of the feature instance this automation belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + label: str = Field(description="User-friendly name", json_schema_extra={"frontend_type": "text", "frontend_required": True}) + schedule: str = Field(description="Cron schedule pattern", json_schema_extra={"frontend_type": "select", "frontend_required": True, "frontend_options": [ + {"value": "0 */4 * * *", "label": {"en": "Every 4 hours", "fr": "Toutes les 4 heures"}}, + {"value": "0 22 * * *", "label": {"en": "Daily at 22:00", "fr": "Quotidien à 22:00"}}, + {"value": "0 10 * * 1", "label": {"en": "Weekly Monday 10:00", "fr": "Hebdomadaire lundi 10:00"}} + ]}) + template: str = Field(description="JSON template with placeholders (format: {{KEY:PLACEHOLDER_NAME}})", json_schema_extra={"frontend_type": "textarea", "frontend_required": True}) + placeholders: Dict[str, str] = Field(default_factory=dict, description="Dictionary of placeholder key/value pairs (e.g., {'connectionName': 'MyConnection', 'sharepointFolderNameSource': '/folder/path', 'webResearchUrl': 'https://...', 'webResearchPrompt': '...', 'documentPrompt': '...'})", json_schema_extra={"frontend_type": "text"}) + active: bool = Field(default=False, description="Whether automation should be launched in event handler", json_schema_extra={"frontend_type": "checkbox", "frontend_required": False}) + eventId: Optional[str] = Field(None, description="Event ID from event management (None if not registered)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + status: Optional[str] = Field(None, description="Status: 'active' if event is registered, 'inactive' if not (computed, readonly)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + executionLogs: List[Dict[str, Any]] = Field(default_factory=list, description="List of execution logs, each containing timestamp, workflowId, status, and messages", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + + +registerModelLabels( + "AutomationDefinition", + {"en": "Automation Definition", "fr": "Définition d'automatisation"}, + { + "id": {"en": "ID", "fr": "ID"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, + "label": {"en": "Label", "fr": "Libellé"}, + "schedule": {"en": "Schedule", "fr": "Planification"}, + "template": {"en": "Template", "fr": "Modèle"}, + "placeholders": {"en": "Placeholders", "fr": "Espaces réservés"}, + "active": {"en": "Active", "fr": "Actif"}, + "eventId": {"en": "Event ID", "fr": "ID de l'événement"}, + "status": {"en": "Status", "fr": "Statut"}, + "executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"}, + }, +) diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index 0d386aac..bc729e0d 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -23,7 +23,7 @@ from modules.datamodels.datamodelRealEstate import ( Land, ) from modules.services import getInterface as getServices -from modules.interfaces.interfaceDbRealEstate import getInterface as getRealEstateInterface +from modules.interfaces.interfaceDbRealestate import getInterface as getRealEstateInterface from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector logger = logging.getLogger(__name__) diff --git a/modules/features/workflow/subAutomationUtils.py b/modules/features/workflow/subAutomationUtils.py index 906c9caa..97d28719 100644 --- a/modules/features/workflow/subAutomationUtils.py +++ b/modules/features/workflow/subAutomationUtils.py @@ -3,7 +3,7 @@ """ Utility functions for automation feature. -Moved from interfaces/interfaceDbChatbot.py. +Moved from interfaces/interfaceDbChat.py. """ import json diff --git a/modules/interfaces/interfaceDbAppObjects.py b/modules/interfaces/interfaceDbApp.py similarity index 100% rename from modules/interfaces/interfaceDbAppObjects.py rename to modules/interfaces/interfaceDbApp.py diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py new file mode 100644 index 00000000..d171c2ca --- /dev/null +++ b/modules/interfaces/interfaceDbChat.py @@ -0,0 +1,1963 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Interface to LucyDOM database and AI Connectors. +Uses the JSON connector for data access with added language support. +""" + +import logging +import uuid +import math +from typing import Dict, Any, List, Optional, Union + +import asyncio + +from modules.security.rbac import RbacClass +from modules.datamodels.datamodelRbac import AccessRuleContext +from modules.datamodels.datamodelUam import AccessLevel + +from modules.datamodels.datamodelChat import ( + ChatDocument, + ChatStat, + ChatLog, + ChatMessage, + ChatWorkflow, + WorkflowModeEnum, + AutomationDefinition, + UserInputRequest +) +import json +from modules.datamodels.datamodelUam import User + +# DYNAMIC PART: Connectors to the Interface +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult +from modules.interfaces.interfaceRbac import getRecordsetWithRBAC + +# Basic Configurations +from modules.shared.configuration import APP_CONFIG +logger = logging.getLogger(__name__) + +# Singleton factory for Chat instances +_chatInterfaces = {} + + +def storeDebugMessageAndDocuments(message, currentUser) -> None: + """ + Store message and documents (metadata and file bytes) for debugging purposes. + Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/ + - message.json, message_text.txt + - document_###_metadata.json + - document_###_ (actual file bytes) + + Args: + message: ChatMessage object to store + currentUser: Current user for component interface access + """ + try: + import os + from datetime import datetime, UTC + from modules.shared.debugLogger import _getBaseDebugDir, _ensureDir + from modules.interfaces.interfaceDbManagement import getInterface + + # Create base debug directory (use base debug dir, not prompts subdirectory) + baseDebugDir = _getBaseDebugDir() + debug_root = os.path.join(baseDebugDir, 'messages') + _ensureDir(debug_root) + + # Generate timestamp + timestamp = datetime.now(UTC).strftime('%Y%m%d-%H%M%S-%f')[:-3] + + # Create message folder name: m_round_task_action_timestamp + # Use actual values from message, not defaults + round_str = str(message.roundNumber) if message.roundNumber is not None else "0" + task_str = str(message.taskNumber) if message.taskNumber is not None else "0" + action_str = str(message.actionNumber) if message.actionNumber is not None else "0" + message_folder = f"{timestamp}_m_{round_str}_{task_str}_{action_str}" + + message_path = os.path.join(debug_root, message_folder) + os.makedirs(message_path, exist_ok=True) + + # Store message data - use dict() instead of model_dump() for compatibility + message_file = os.path.join(message_path, "message.json") + with open(message_file, "w", encoding="utf-8") as f: + # Convert message to dict manually to avoid model_dump() issues + message_dict = { + "id": message.id, + "workflowId": message.workflowId, + "parentMessageId": message.parentMessageId, + "message": message.message, + "role": message.role, + "status": message.status, + "sequenceNr": message.sequenceNr, + "publishedAt": message.publishedAt, + "roundNumber": message.roundNumber, + "taskNumber": message.taskNumber, + "actionNumber": message.actionNumber, + "documentsLabel": message.documentsLabel, + "actionId": message.actionId, + "actionMethod": message.actionMethod, + "actionName": message.actionName, + "success": message.success, + "documents": [] + } + json.dump(message_dict, f, indent=2, ensure_ascii=False, default=str) + + # Store message content as text + if message.message: + message_text_file = os.path.join(message_path, "message_text.txt") + with open(message_text_file, "w", encoding="utf-8") as f: + f.write(str(message.message)) + + # Store documents if provided + if message.documents and len(message.documents) > 0: + # Group documents by documentsLabel + documents_by_label = {} + for doc in message.documents: + label = message.documentsLabel or 'default' + if label not in documents_by_label: + documents_by_label[label] = [] + documents_by_label[label].append(doc) + + # Create subfolder for each document label + for label, docs in documents_by_label.items(): + # Sanitize label for filesystem + safe_label = "".join(c for c in str(label) if c.isalnum() or c in (' ', '-', '_')).rstrip() + safe_label = safe_label.replace(' ', '_') + if not safe_label: + safe_label = "default" + + label_folder = os.path.join(message_path, safe_label) + _ensureDir(label_folder) + + # Store each document + for i, doc in enumerate(docs): + # Create document metadata file + doc_meta = { + "id": doc.id, + "messageId": doc.messageId, + "fileId": doc.fileId, + "fileName": doc.fileName, + "fileSize": doc.fileSize, + "mimeType": doc.mimeType, + "roundNumber": doc.roundNumber, + "taskNumber": doc.taskNumber, + "actionNumber": doc.actionNumber, + "actionId": doc.actionId + } + + doc_meta_file = os.path.join(label_folder, f"document_{i+1:03d}_metadata.json") + with open(doc_meta_file, "w", encoding="utf-8") as f: + json.dump(doc_meta, f, indent=2, ensure_ascii=False, default=str) + + # Also store the actual file bytes next to metadata for debugging + try: + componentInterface = getInterface(currentUser) + file_bytes = componentInterface.getFileData(doc.fileId) + if file_bytes: + # Build a safe filename preserving original name + safe_name = doc.fileName or f"document_{i+1:03d}" + # Avoid path traversal + safe_name = os.path.basename(safe_name) + doc_file_path = os.path.join(label_folder, f"document_{i+1:03d}_" + safe_name) + with open(doc_file_path, "wb") as df: + df.write(file_bytes) + else: + pass + except Exception as e: + pass + + except Exception as e: + # Silent fail - don't break main flow + pass + +class ChatObjects: + """ + Interface to Chat database and AI Connectors. + Uses the JSON connector for data access with added language support. + """ + + def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): + """Initializes the Chat Interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header) + """ + # Initialize variables + self.currentUser = currentUser # Store User object directly + self.userId = currentUser.id if currentUser else None + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId + self.featureInstanceId = featureInstanceId + self.rbac = None # RBAC interface + + # Initialize services + self._initializeServices() + + # Initialize database + self._initializeDatabase() + + # Set user context if provided + if currentUser: + self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) + + # ===== Generic Utility Methods ===== + + def _isObjectField(self, fieldType) -> bool: + """Check if a field type represents a complex object (not a simple type).""" + # Simple scalar types + if fieldType in (str, int, float, bool, type(None)): + return False + + # Everything else is an object + return True + + def _separateObjectFields(self, model_class, data: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any]]: + """Separate simple fields from object fields based on Pydantic model structure.""" + simpleFields = {} + objectFields = {} + + # Get field information from the Pydantic model + modelFields = model_class.model_fields + + for fieldName, value in data.items(): + # Check if this field should be stored as JSONB in the database + if fieldName in modelFields: + fieldInfo = modelFields[fieldName] + # Pydantic v2 only + fieldType = fieldInfo.annotation + + # Always route relational/object fields to object_fields for separate handling + # These fields are stored in separate normalized tables, not as JSONB + if fieldName in ['documents', 'stats', 'logs', 'messages']: + objectFields[fieldName] = value + continue + + # Check if this is a JSONB field (Dict, List, or complex types) + # Purely type-based detection - no hardcoded field names + if (fieldType == dict or + fieldType == list or + (hasattr(fieldType, '__origin__') and fieldType.__origin__ in (dict, list))): + # Store as JSONB - include in simple_fields for database storage + simpleFields[fieldName] = value + elif isinstance(value, (str, int, float, bool, type(None))): + # Simple scalar types + simpleFields[fieldName] = value + else: + # Complex objects that should be filtered out + objectFields[fieldName] = value + else: + # Field not in model - treat as scalar if simple, otherwise filter out + # BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector + if fieldName.startswith("_"): + # Metadata fields should be passed through to connector + simpleFields[fieldName] = value + elif isinstance(value, (str, int, float, bool, type(None))): + simpleFields[fieldName] = value + else: + objectFields[fieldName] = value + + return simpleFields, objectFields + + def _initializeServices(self): + pass + + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): + """Sets the user context for the interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header) + """ + self.currentUser = currentUser # Store User object directly + self.userId = currentUser.id + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId + self.featureInstanceId = featureInstanceId + + if not self.userId: + raise ValueError("Invalid user context: id is required") + + # mandateId can be None for sysadmins performing cross-mandate operations + if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False): + raise ValueError("Invalid user context: mandateId is required for non-sysadmin users") + + # Add language settings + self.userLanguage = currentUser.language # Default user language + + # Initialize RBAC interface + if not self.currentUser: + raise ValueError("User context is required for RBAC") + # Get DbApp connection for RBAC AccessRule queries + from modules.security.rootAccess import getRootDbAppConnector + dbApp = getRootDbAppConnector() + self.rbac = RbacClass(self.db, dbApp=dbApp) + + # Update database context + self.db.updateContext(self.userId) + + def __del__(self): + """Cleanup method to close database connection.""" + if hasattr(self, 'db') and self.db is not None: + try: + self.db.close() + except Exception as e: + logger.error(f"Error closing database connection: {e}") + + + def _initializeDatabase(self): + """Initializes the database connection directly.""" + try: + # Get configuration values with defaults + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_chat" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) + + # Create database connector directly + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=self.userId + ) + + # Initialize database system + self.db.initDbSystem() + + logger.info("Database initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize database: {str(e)}") + raise + + def _initRecords(self): + """Initializes standard records in the database if they don't exist.""" + pass + + + def checkRbacPermission( + self, + modelClass: type, + operation: str, + recordId: Optional[str] = None + ) -> bool: + """ + Check RBAC permission for a specific operation on a table. + + Args: + modelClass: Pydantic model class for the table + operation: Operation to check ('create', 'update', 'delete', 'read') + recordId: Optional record ID for specific record check + + Returns: + Boolean indicating permission + """ + if not self.rbac or not self.currentUser: + return False + + tableName = modelClass.__name__ + permissions = self.rbac.getUserPermissions( + self.currentUser, + AccessRuleContext.DATA, + tableName + ) + + if operation == "create": + return permissions.create != AccessLevel.NONE + elif operation == "update": + return permissions.update != AccessLevel.NONE + elif operation == "delete": + return permissions.delete != AccessLevel.NONE + elif operation == "read": + return permissions.read != AccessLevel.NONE + else: + return False + + def _applyFilters(self, records: List[Dict[str, Any]], filters: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Apply filter criteria to records. + + Supports: + - General search: {"search": "text"} - searches across all text fields + - Field-specific filters: + - Simple: {"status": "running"} - equals match + - With operator: {"status": {"operator": "equals", "value": "running"}} + - Operators: equals, contains, gt, gte, lt, lte, in, notIn, startsWith, endsWith + + Args: + records: List of record dictionaries to filter + filters: Filter criteria dictionary + + Returns: + Filtered list of records + """ + if not filters or not records: + return records + + filtered = [] + + for record in records: + matches = True + + # Handle general search across text fields + if "search" in filters: + search_term = str(filters["search"]).lower() + if search_term: + # Search in all string fields + found = False + for key, value in record.items(): + if isinstance(value, str) and search_term in value.lower(): + found = True + break + elif isinstance(value, (int, float)) and search_term in str(value): + found = True + break + if not found: + matches = False + + # Handle field-specific filters + for field_name, filter_value in filters.items(): + if field_name == "search": + continue # Already handled above + + if field_name not in record: + matches = False + break + + record_value = record.get(field_name) + + # Handle simple value (equals operator) + if not isinstance(filter_value, dict): + if record_value != filter_value: + matches = False + break + continue + + # Handle filter with operator + operator = filter_value.get("operator", "equals") + filter_val = filter_value.get("value") + + if operator in ["equals", "eq"]: + if record_value != filter_val: + matches = False + break + + elif operator == "contains": + record_str = str(record_value).lower() if record_value is not None else "" + filter_str = str(filter_val).lower() if filter_val is not None else "" + if filter_str not in record_str: + matches = False + break + + elif operator == "startsWith": + record_str = str(record_value).lower() if record_value is not None else "" + filter_str = str(filter_val).lower() if filter_val is not None else "" + if not record_str.startswith(filter_str): + matches = False + break + + elif operator == "endsWith": + record_str = str(record_value).lower() if record_value is not None else "" + filter_str = str(filter_val).lower() if filter_val is not None else "" + if not record_str.endswith(filter_str): + matches = False + break + + elif operator == "gt": + try: + record_num = float(record_value) if record_value is not None else float('-inf') + filter_num = float(filter_val) if filter_val is not None else float('-inf') + if record_num <= filter_num: + matches = False + break + except (ValueError, TypeError): + matches = False + break + + elif operator == "gte": + try: + record_num = float(record_value) if record_value is not None else float('-inf') + filter_num = float(filter_val) if filter_val is not None else float('-inf') + if record_num < filter_num: + matches = False + break + except (ValueError, TypeError): + matches = False + break + + elif operator == "lt": + try: + record_num = float(record_value) if record_value is not None else float('inf') + filter_num = float(filter_val) if filter_val is not None else float('inf') + if record_num >= filter_num: + matches = False + break + except (ValueError, TypeError): + matches = False + break + + elif operator == "lte": + try: + record_num = float(record_value) if record_value is not None else float('inf') + filter_num = float(filter_val) if filter_val is not None else float('inf') + if record_num > filter_num: + matches = False + break + except (ValueError, TypeError): + matches = False + break + + elif operator == "in": + if not isinstance(filter_val, list): + filter_val = [filter_val] + if record_value not in filter_val: + matches = False + break + + elif operator == "notIn": + if not isinstance(filter_val, list): + filter_val = [filter_val] + if record_value in filter_val: + matches = False + break + + else: + # Unknown operator - default to equals + if record_value != filter_val: + matches = False + break + + if matches: + filtered.append(record) + + return filtered + + def _applySorting(self, records: List[Dict[str, Any]], sortFields: List[Any]) -> List[Dict[str, Any]]: + """Apply multi-level sorting to records using stable sort (sorts from least to most significant field).""" + if not sortFields: + return records + + # Start with a copy to avoid modifying original + sortedRecords = list(records) + + # Sort from least significant to most significant field (reverse order) + # Python's sort is stable, so this creates proper multi-level sorting + for sortField in reversed(sortFields): + # Handle both dict and object formats + if isinstance(sortField, dict): + fieldName = sortField.get("field") + direction = sortField.get("direction", "asc") + else: + fieldName = getattr(sortField, "field", None) + direction = getattr(sortField, "direction", "asc") + + if not fieldName: + continue + + isDesc = (direction == "desc") + + def sortKey(record): + value = record.get(fieldName) + # Handle None values - place them at the end for both directions + if value is None: + # Use a special value that sorts last + return (1, "") # (is_none_flag, empty_value) - sorts after (0, ...) + else: + # Return tuple with type indicator for proper comparison + if isinstance(value, (int, float)): + return (0, value) + elif isinstance(value, str): + return (0, value) + elif isinstance(value, bool): + return (0, value) + else: + return (0, str(value)) + + # Sort with reverse parameter for descending + sortedRecords.sort(key=sortKey, reverse=isDesc) + + return sortedRecords + + # Utilities + + def getInitialId(self, model_class: type) -> Optional[str]: + """Returns the initial ID for a table.""" + return self.db.getInitialId(model_class) + + + + # Workflow methods + + def getWorkflows(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: + """ + Returns workflows based on user access level. + Supports optional pagination, sorting, and filtering. + + Args: + pagination: Optional pagination parameters. If None, returns all items. + + Returns: + If pagination is None: List[Dict[str, Any]] + If pagination is provided: PaginatedResult with items and metadata + """ + # Use RBAC filtering with featureInstanceId for instance-level isolation + filteredWorkflows = getRecordsetWithRBAC(self.db, + ChatWorkflow, + self.currentUser, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId + ) + + # If no pagination requested, return all items (no sorting - frontend handles it) + if pagination is None: + return filteredWorkflows + + # Apply filtering (if filters provided) + if pagination.filters: + filteredWorkflows = self._applyFilters(filteredWorkflows, pagination.filters) + + # Apply sorting (in order of sortFields) - only if provided by frontend + if pagination.sort: + filteredWorkflows = self._applySorting(filteredWorkflows, pagination.sort) + + # Count total items after filters + totalItems = len(filteredWorkflows) + totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 + + # Apply pagination (skip/limit) + startIdx = (pagination.page - 1) * pagination.pageSize + endIdx = startIdx + pagination.pageSize + pagedWorkflows = filteredWorkflows[startIdx:endIdx] + + return PaginatedResult( + items=pagedWorkflows, + totalItems=totalItems, + totalPages=totalPages + ) + + def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]: + """Returns a workflow by ID if user has access.""" + # Use RBAC filtering with featureInstanceId for instance-level isolation + workflows = getRecordsetWithRBAC(self.db, + ChatWorkflow, + self.currentUser, + recordFilter={"id": workflowId}, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId + ) + + if not workflows: + return None + + workflow = workflows[0] + try: + # Load related data from normalized tables + logs = self.getLogs(workflowId) + messages = self.getMessages(workflowId) + stats = self.getStats(workflowId) + + # Validate workflow data against ChatWorkflow model + return ChatWorkflow( + id=workflow["id"], + status=workflow.get("status", "running"), + name=workflow.get("name"), + currentRound=workflow.get("currentRound", 0) or 0, + currentTask=workflow.get("currentTask", 0) or 0, + currentAction=workflow.get("currentAction", 0) or 0, + totalTasks=workflow.get("totalTasks", 0) or 0, + totalActions=workflow.get("totalActions", 0) or 0, + lastActivity=workflow.get("lastActivity", getUtcTimestamp()), + startedAt=workflow.get("startedAt", getUtcTimestamp()), + logs=logs, + messages=messages, + stats=stats, + mandateId=workflow.get("mandateId", self.mandateId) + ) + except Exception as e: + logger.error(f"Error validating workflow data: {str(e)}") + return None + + def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow: + """Creates a new workflow if user has permission.""" + if not self.checkRbacPermission(ChatWorkflow, "create"): + raise PermissionError("No permission to create workflows") + + # Set timestamp if not present + currentTime = getUtcTimestamp() + if "startedAt" not in workflowData: + workflowData["startedAt"] = currentTime + + if "lastActivity" not in workflowData: + workflowData["lastActivity"] = currentTime + + # Set mandateId and featureInstanceId from context for proper data isolation + if "mandateId" not in workflowData or not workflowData["mandateId"]: + workflowData["mandateId"] = self.mandateId + if "featureInstanceId" not in workflowData or not workflowData["featureInstanceId"]: + workflowData["featureInstanceId"] = self.featureInstanceId + + # Use generic field separation based on ChatWorkflow model + simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData) + + # Create workflow in database + created = self.db.recordCreate(ChatWorkflow, simpleFields) + + + # Convert to ChatWorkflow model (empty related data for new workflow) + return ChatWorkflow( + id=created["id"], + status=created.get("status", "running"), + name=created.get("name"), + currentRound=created.get("currentRound", 0) or 0, + currentTask=created.get("currentTask", 0) or 0, + currentAction=created.get("currentAction", 0) or 0, + totalTasks=created.get("totalTasks", 0) or 0, + totalActions=created.get("totalActions", 0) or 0, + lastActivity=created.get("lastActivity", currentTime), + startedAt=created.get("startedAt", currentTime), + logs=[], + messages=[], + stats=[], + mandateId=created.get("mandateId", self.mandateId), + workflowMode=created["workflowMode"], + maxSteps=created.get("maxSteps", 1) + ) + + def updateWorkflow(self, workflowId: str, workflowData: Dict[str, Any]) -> ChatWorkflow: + """Updates a workflow if user has access.""" + # Check if the workflow exists and user has access + workflow = self.getWorkflow(workflowId) + if not workflow: + return None + + if not self.checkRbacPermission(ChatWorkflow, "update", workflowId): + raise PermissionError(f"No permission to update workflow {workflowId}") + + # Use generic field separation based on ChatWorkflow model + simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData) + + # Set update time for main workflow + simpleFields["lastActivity"] = getUtcTimestamp() + + # Update main workflow in database + updated = self.db.recordModify(ChatWorkflow, workflowId, simpleFields) + + # Removed cascade writes for logs/messages/stats during workflow update. + # CUD for child entities must be executed via dedicated service methods. + + # Load fresh data from normalized tables + logs = self.getLogs(workflowId) + messages = self.getMessages(workflowId) + stats = self.getStats(workflowId) + + # Convert to ChatWorkflow model + return ChatWorkflow( + id=updated["id"], + status=updated.get("status", workflow.status), + name=updated.get("name", workflow.name), + currentRound=updated.get("currentRound", workflow.currentRound), + currentTask=updated.get("currentTask", workflow.currentTask), + currentAction=updated.get("currentAction", workflow.currentAction), + totalTasks=updated.get("totalTasks", workflow.totalTasks), + totalActions=updated.get("totalActions", workflow.totalActions), + lastActivity=updated.get("lastActivity", workflow.lastActivity), + startedAt=updated.get("startedAt", workflow.startedAt), + logs=logs, + messages=messages, + stats=stats, + mandateId=updated.get("mandateId", workflow.mandateId) + ) + + def deleteWorkflow(self, workflowId: str) -> bool: + """Deletes a workflow and all related data if user has access.""" + try: + # Check if the workflow exists and user has access + workflow = self.getWorkflow(workflowId) + if not workflow: + return False + + if not self.checkRbacPermission(ChatWorkflow, "delete", workflowId): + raise PermissionError(f"No permission to delete workflow {workflowId}") + + # CASCADE DELETE: Delete all related data first + + # 1. Delete all workflow messages and their related data + messages = self.getMessages(workflowId) + for message in messages: + messageId = message.id + if messageId: + # Delete message stats + existing_stats = getRecordsetWithRBAC(self.db, ChatStat, self.currentUser, recordFilter={"messageId": messageId}) + for stat in existing_stats: + self.db.recordDelete(ChatStat, stat["id"]) + + # Delete message documents (but NOT the files!) + existing_docs = getRecordsetWithRBAC(self.db, ChatDocument, self.currentUser, recordFilter={"messageId": messageId}) + for doc in existing_docs: + self.db.recordDelete(ChatDocument, doc["id"]) + + # Delete the message itself + self.db.recordDelete(ChatMessage, messageId) + + # 2. Delete workflow stats + existing_stats = getRecordsetWithRBAC(self.db, ChatStat, self.currentUser, recordFilter={"workflowId": workflowId}) + for stat in existing_stats: + self.db.recordDelete(ChatStat, stat["id"]) + + # 3. Delete workflow logs + existing_logs = getRecordsetWithRBAC(self.db, ChatLog, self.currentUser, recordFilter={"workflowId": workflowId}) + for log in existing_logs: + self.db.recordDelete(ChatLog, log["id"]) + + # 4. Finally delete the workflow itself + success = self.db.recordDelete(ChatWorkflow, workflowId) + + return success + + except Exception as e: + logger.error(f"Error deleting workflow {workflowId}: {str(e)}") + return False + + + # Message methods + + def getMessages(self, workflowId: str, pagination: Optional[PaginationParams] = None) -> Union[List[ChatMessage], PaginatedResult]: + """ + Returns messages for a workflow if user has access to the workflow. + Supports optional pagination, sorting, and filtering. + + Args: + workflowId: The workflow ID to get messages for + pagination: Optional pagination parameters. If None, returns all items. + + Returns: + If pagination is None: List[ChatMessage] + If pagination is provided: PaginatedResult with items and metadata + """ + # Check workflow access first (without calling getWorkflow to avoid circular reference) + # Use RBAC filtering + workflows = getRecordsetWithRBAC(self.db, + ChatWorkflow, + self.currentUser, + recordFilter={"id": workflowId} + ) + + if not workflows: + if pagination is None: + return [] + return PaginatedResult(items=[], totalItems=0, totalPages=0) + + # Get messages for this workflow from normalized table + messages = getRecordsetWithRBAC(self.db, ChatMessage, self.currentUser, recordFilter={"workflowId": workflowId}) + + # Convert raw messages to dict format for sorting/filtering + messageDicts = [] + for msg in messages: + messageDicts.append({ + "id": msg.get("id"), + "workflowId": msg.get("workflowId"), + "parentMessageId": msg.get("parentMessageId"), + "documentsLabel": msg.get("documentsLabel"), + "message": msg.get("message"), + "role": msg.get("role", "assistant"), + "status": msg.get("status", "step"), + "sequenceNr": msg.get("sequenceNr", 0), + "publishedAt": msg.get("publishedAt", msg.get("timestamp", getUtcTimestamp())), + "success": msg.get("success"), + "actionId": msg.get("actionId"), + "actionMethod": msg.get("actionMethod"), + "actionName": msg.get("actionName"), + "roundNumber": msg.get("roundNumber"), + "taskNumber": msg.get("taskNumber"), + "actionNumber": msg.get("actionNumber"), + "taskProgress": msg.get("taskProgress"), + "actionProgress": msg.get("actionProgress") + }) + + # Apply default sorting by publishedAt if no sort specified + if pagination is None or not pagination.sort: + messageDicts.sort(key=lambda x: x.get("publishedAt", getUtcTimestamp())) + + # Apply filtering (if filters provided) + if pagination and pagination.filters: + messageDicts = self._applyFilters(messageDicts, pagination.filters) + + # Apply sorting (in order of sortFields) + if pagination and pagination.sort: + messageDicts = self._applySorting(messageDicts, pagination.sort) + + # If no pagination requested, return all items + if pagination is None: + # Convert messages to ChatMessage objects and load documents + chat_messages = [] + for msg in messageDicts: + # Load documents from normalized documents table + documents = self.getDocuments(msg["id"]) + + # Create ChatMessage object with loaded documents + chat_message = ChatMessage( + id=msg["id"], + workflowId=msg["workflowId"], + parentMessageId=msg.get("parentMessageId"), + documents=documents, + documentsLabel=msg.get("documentsLabel"), + message=msg.get("message"), + role=msg.get("role", "assistant"), + status=msg.get("status", "step"), + sequenceNr=msg.get("sequenceNr", 0), + publishedAt=msg.get("publishedAt", getUtcTimestamp()), + success=msg.get("success"), + actionId=msg.get("actionId"), + actionMethod=msg.get("actionMethod"), + actionName=msg.get("actionName"), + roundNumber=msg.get("roundNumber"), + taskNumber=msg.get("taskNumber"), + actionNumber=msg.get("actionNumber"), + taskProgress=msg.get("taskProgress"), + actionProgress=msg.get("actionProgress") + ) + + chat_messages.append(chat_message) + + return chat_messages + + # Count total items after filters + totalItems = len(messageDicts) + totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 + + # Apply pagination (skip/limit) + startIdx = (pagination.page - 1) * pagination.pageSize + endIdx = startIdx + pagination.pageSize + pagedMessageDicts = messageDicts[startIdx:endIdx] + + # Convert messages to ChatMessage objects and load documents + chat_messages = [] + for msg in pagedMessageDicts: + # Load documents from normalized documents table + documents = self.getDocuments(msg["id"]) + + # Create ChatMessage object with loaded documents + chat_message = ChatMessage( + id=msg["id"], + workflowId=msg["workflowId"], + parentMessageId=msg.get("parentMessageId"), + documents=documents, + documentsLabel=msg.get("documentsLabel"), + message=msg.get("message"), + role=msg.get("role", "assistant"), + status=msg.get("status", "step"), + sequenceNr=msg.get("sequenceNr", 0), + publishedAt=msg.get("publishedAt", getUtcTimestamp()), + success=msg.get("success"), + actionId=msg.get("actionId"), + actionMethod=msg.get("actionMethod"), + actionName=msg.get("actionName"), + roundNumber=msg.get("roundNumber"), + taskNumber=msg.get("taskNumber"), + actionNumber=msg.get("actionNumber"), + taskProgress=msg.get("taskProgress"), + actionProgress=msg.get("actionProgress") + ) + + chat_messages.append(chat_message) + + return PaginatedResult( + items=chat_messages, + totalItems=totalItems, + totalPages=totalPages + ) + + def createMessage(self, messageData: Dict[str, Any]) -> ChatMessage: + """Creates a message for a workflow if user has access.""" + try: + # Ensure ID is present + if "id" not in messageData or not messageData["id"]: + messageData["id"] = f"msg_{uuid.uuid4()}" + # Check required fields + requiredFields = ["id", "workflowId"] + for field in requiredFields: + if field not in messageData: + logger.error(f"Required field '{field}' missing in messageData") + raise ValueError(f"Required field '{field}' missing in message data") + + # Check workflow access + workflowId = messageData["workflowId"] + workflow = self.getWorkflow(workflowId) + if not workflow: + raise PermissionError(f"No access to workflow {workflowId}") + + if not self.checkRbacPermission(ChatWorkflow, "update", workflowId): + raise PermissionError(f"No permission to modify workflow {workflowId}") + + # Validate that ID is not None + if messageData["id"] is None: + messageData["id"] = f"msg_{uuid.uuid4()}" + logger.warning(f"Automatically generated ID for workflow message: {messageData['id']}") + + # Set status if not present + if "status" not in messageData: + messageData["status"] = "step" # Default status for intermediate messages + + # Ensure role and agentName are present + if "role" not in messageData: + messageData["role"] = "assistant" if messageData.get("agentName") else "user" + + if "agentName" not in messageData: + messageData["agentName"] = "" + + # CRITICAL FIX: Automatically set roundNumber, taskNumber, and actionNumber if not provided + # This ensures messages have the correct progress context when workflows are continued + if "roundNumber" not in messageData: + messageData["roundNumber"] = workflow.currentRound + + if "taskNumber" not in messageData: + messageData["taskNumber"] = workflow.currentTask + + if "actionNumber" not in messageData: + messageData["actionNumber"] = workflow.currentAction + + # Set mandateId and featureInstanceId from context for proper data isolation + if "mandateId" not in messageData or not messageData["mandateId"]: + messageData["mandateId"] = self.mandateId + if "featureInstanceId" not in messageData or not messageData["featureInstanceId"]: + messageData["featureInstanceId"] = self.featureInstanceId + + # Use generic field separation based on ChatMessage model + simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData) + + # Handle documents separately - they will be stored in normalized documents table + documents_to_create = objectFields.get("documents", []) + + # Create message in normalized table using only simple fields + createdMessage = self.db.recordCreate(ChatMessage, simpleFields) + + + # Create documents in normalized documents table + created_documents = [] + logger.debug(f"Creating {len(documents_to_create)} document(s) for message {createdMessage['id']}") + for idx, doc_data in enumerate(documents_to_create): + try: + # Normalize to plain dict before assignment + if isinstance(doc_data, ChatDocument): + doc_dict = doc_data.model_dump() + elif isinstance(doc_data, dict): + doc_dict = dict(doc_data) + else: + # Attempt to coerce to ChatDocument then dump + try: + doc_dict = ChatDocument(**doc_data).model_dump() + except Exception as e: + logger.error(f"Invalid document data type for message creation (document {idx + 1}/{len(documents_to_create)}): {e}") + continue + + # Ensure messageId is set + doc_dict["messageId"] = createdMessage["id"] + logger.debug(f"Creating document {idx + 1}/{len(documents_to_create)}: fileName={doc_dict.get('fileName', 'unknown')}, fileId={doc_dict.get('fileId', 'unknown')}, messageId={doc_dict.get('messageId', 'unknown')}") + + created_doc = self.createDocument(doc_dict) + if created_doc: + created_documents.append(created_doc) + logger.debug(f"Successfully created document {idx + 1}/{len(documents_to_create)}: {created_doc.fileName} (id: {created_doc.id})") + else: + logger.error(f"Failed to create document {idx + 1}/{len(documents_to_create)}: createDocument returned None for fileName={doc_dict.get('fileName', 'unknown')}") + except Exception as e: + logger.error(f"Error processing document {idx + 1}/{len(documents_to_create)}: {e}", exc_info=True) + + logger.info(f"Created {len(created_documents)}/{len(documents_to_create)} document(s) for message {createdMessage['id']}") + + # Convert to ChatMessage model + chat_message = ChatMessage( + id=createdMessage["id"], + workflowId=createdMessage["workflowId"], + parentMessageId=createdMessage.get("parentMessageId"), + agentName=createdMessage.get("agentName"), + documents=created_documents, + documentsLabel=createdMessage.get("documentsLabel"), + message=createdMessage.get("message"), + role=createdMessage.get("role", "assistant"), + status=createdMessage.get("status", "step"), + sequenceNr=len(workflow.messages) + 1, # Use messages list length for sequence number + publishedAt=createdMessage.get("publishedAt", getUtcTimestamp()), + stats=objectFields.get("stats"), # Use stats from objectFields + roundNumber=createdMessage.get("roundNumber"), + taskNumber=createdMessage.get("taskNumber"), + actionNumber=createdMessage.get("actionNumber"), + success=createdMessage.get("success"), + actionId=createdMessage.get("actionId"), + actionMethod=createdMessage.get("actionMethod"), + actionName=createdMessage.get("actionName") + ) + + # Emit message event for streaming (if event manager is available) + try: + from modules.features.chatbot.eventManager import get_event_manager + event_manager = get_event_manager() + message_timestamp = parseTimestamp(chat_message.publishedAt, default=getUtcTimestamp()) + # Emit message event in exact chatData format: {type, createdAt, item} + asyncio.create_task(event_manager.emit_event( + context_id=workflowId, + event_type="chatdata", + data={ + "type": "message", + "createdAt": message_timestamp, + "item": chat_message.dict() + }, + event_category="chat" + )) + except Exception as e: + # Event manager not available or error - continue without emitting + logger.debug(f"Could not emit message event: {e}") + + # Debug: Store message and documents for debugging - only if debug enabled + storeDebugMessageAndDocuments(chat_message, self.currentUser) + + return chat_message + + except Exception as e: + logger.error(f"Error creating workflow message: {str(e)}") + return None + + def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Optional[ChatMessage]: + """Updates a workflow message if user has access to the workflow.""" + try: + + # Ensure messageId is provided + if not messageId: + logger.error("No messageId provided for updateMessage") + raise ValueError("messageId cannot be empty") + + # Check if message exists in database + messages = getRecordsetWithRBAC(self.db, ChatMessage, self.currentUser, recordFilter={"id": messageId}) + if not messages: + logger.warning(f"Message with ID {messageId} does not exist in database") + + # If message doesn't exist but we have workflowId, create it + if "workflowId" in messageData: + workflowId = messageData.get("workflowId") + + # Check workflow access + workflow = self.getWorkflow(workflowId) + if not workflow: + raise PermissionError(f"No access to workflow {workflowId}") + + if not self.checkRbacPermission(ChatWorkflow, "update", workflowId): + raise PermissionError(f"No permission to modify workflow {workflowId}") + + logger.info(f"Creating new message with ID {messageId} for workflow {workflowId}") + return self.db.recordCreate(ChatMessage, messageData) + else: + logger.error(f"Workflow ID missing for new message {messageId}") + return None + + # Update existing message + existingMessage = messages[0] + + # Check workflow access + workflowId = existingMessage.get("workflowId") + workflow = self.getWorkflow(workflowId) + if not workflow: + raise PermissionError(f"No access to workflow {workflowId}") + + if not self.checkRbacPermission(ChatWorkflow, "update", workflowId): + raise PermissionError(f"No permission to modify workflow {workflowId}") + + # Use generic field separation based on ChatMessage model + simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData) + + # Ensure required fields present + for key in ["role", "agentName"]: + if key not in simpleFields and key not in existingMessage: + simpleFields[key] = "assistant" if key == "role" else "" + + # Ensure ID is in the dataset + if 'id' not in simpleFields: + simpleFields['id'] = messageId + + # Convert createdAt to startedAt if needed + if "createdAt" in simpleFields and "startedAt" not in simpleFields: + simpleFields["startedAt"] = simpleFields["createdAt"] + del simpleFields["createdAt"] + + # Update the message with simple fields only + updatedMessage = self.db.recordModify(ChatMessage, messageId, simpleFields) + + # Handle object field updates (documents, stats) inline + if 'documents' in objectFields: + documents_data = objectFields['documents'] + try: + for doc_data in documents_data: + # Normalize to dict before mutation + if isinstance(doc_data, ChatDocument): + doc_dict = doc_data.model_dump() + elif isinstance(doc_data, dict): + doc_dict = dict(doc_data) + else: + try: + doc_dict = ChatDocument(**doc_data).model_dump() + except Exception: + logger.error("Invalid document data type for message update") + continue + doc_dict["messageId"] = messageId + self.createDocument(doc_dict) + except Exception as e: + logger.error(f"Error updating message documents: {str(e)}") + if not updatedMessage: + logger.warning(f"Failed to update message {messageId}") + return None + + # Convert to ChatMessage model + return ChatMessage(**updatedMessage) + except Exception as e: + logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True) + raise ValueError(f"Error updating message {messageId}: {str(e)}") + + def deleteMessage(self, workflowId: str, messageId: str) -> bool: + """Deletes a workflow message and all related data if user has access to the workflow.""" + try: + # Check workflow access + workflow = self.getWorkflow(workflowId) + if not workflow: + logger.warning(f"No access to workflow {workflowId}") + return False + + if not self.checkRbacPermission(ChatWorkflow, "update", workflowId): + raise PermissionError(f"No permission to modify workflow {workflowId}") + + # Check if the message exists + messages = self.getMessages(workflowId) + message = next((m for m in messages if m.get("id") == messageId), None) + + if not message: + logger.warning(f"Message {messageId} for workflow {workflowId} not found") + return False + + # CASCADE DELETE: Delete all related data first + + # 1. Delete message stats + existing_stats = getRecordsetWithRBAC(self.db, ChatStat, self.currentUser, recordFilter={"messageId": messageId}) + for stat in existing_stats: + self.db.recordDelete(ChatStat, stat["id"]) + + # 2. Delete message documents (but NOT the files!) + existing_docs = getRecordsetWithRBAC(self.db, ChatDocument, self.currentUser, recordFilter={"messageId": messageId}) + for doc in existing_docs: + self.db.recordDelete(ChatDocument, doc["id"]) + + # 3. Finally delete the message itself + success = self.db.recordDelete(ChatMessage, messageId) + + return success + + except Exception as e: + logger.error(f"Error deleting message {messageId}: {str(e)}") + return False + + def deleteFileFromMessage(self, workflowId: str, messageId: str, fileId: str) -> bool: + """Removes a file reference from a message if user has access.""" + try: + # Check workflow access + workflow = self.getWorkflow(workflowId) + if not workflow: + logger.warning(f"No access to workflow {workflowId}") + return False + + if not self.checkRbacPermission(ChatWorkflow, "update", workflowId): + raise PermissionError(f"No permission to modify workflow {workflowId}") + + + # Get documents for this message from normalized table + documents = getRecordsetWithRBAC(self.db, ChatDocument, self.currentUser, recordFilter={"messageId": messageId}) + + if not documents: + logger.warning(f"No documents found for message {messageId}") + return False + + # Find and delete the specific document + removed = False + for doc in documents: + docId = doc.get("id") + fileIdValue = doc.get("fileId") + + # Flexible matching approach + shouldRemove = ( + (docId == fileId) or + (fileIdValue == fileId) or + (isinstance(docId, str) and str(fileId) in docId) or + (isinstance(fileIdValue, str) and str(fileId) in fileIdValue) + ) + + if shouldRemove: + # Delete the document from normalized table + success = self.db.recordDelete(ChatDocument, docId) + if success: + removed = True + else: + logger.warning(f"Failed to delete document {docId}") + + if not removed: + logger.warning(f"No matching file {fileId} found in message {messageId}") + return False + + except Exception as e: + logger.error(f"Error removing file {fileId} from message {messageId}: {str(e)}") + return False + + # Document methods + + def getDocuments(self, messageId: str) -> List[ChatDocument]: + """Returns documents for a message from normalized table.""" + try: + documents = getRecordsetWithRBAC(self.db, ChatDocument, self.currentUser, recordFilter={"messageId": messageId}) + return [ChatDocument(**doc) for doc in documents] + except Exception as e: + logger.error(f"Error getting message documents: {str(e)}") + return [] + + def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument: + """Creates a document for a message in normalized table.""" + try: + # Set mandateId and featureInstanceId from context for proper data isolation + if "mandateId" not in documentData or not documentData["mandateId"]: + documentData["mandateId"] = self.mandateId + if "featureInstanceId" not in documentData or not documentData["featureInstanceId"]: + documentData["featureInstanceId"] = self.featureInstanceId + + # Validate and normalize document data to dict + document = ChatDocument(**documentData) + logger.debug(f"Creating document in database: fileName={document.fileName}, fileId={document.fileId}, messageId={document.messageId}") + created = self.db.recordCreate(ChatDocument, document.model_dump()) + + if created: + created_doc = ChatDocument(**created) + logger.debug(f"Successfully created document in database: {created_doc.fileName} (id: {created_doc.id})") + return created_doc + else: + logger.error(f"Failed to create document in database: recordCreate returned None for fileName={document.fileName}") + return None + except Exception as e: + logger.error(f"Error creating message document: {str(e)}", exc_info=True) + return None + + + # Log methods + + def getLogs(self, workflowId: str, pagination: Optional[PaginationParams] = None) -> Union[List[ChatLog], PaginatedResult]: + """ + Returns logs for a workflow if user has access to the workflow. + Supports optional pagination, sorting, and filtering. + + Args: + workflowId: The workflow ID to get logs for + pagination: Optional pagination parameters. If None, returns all items. + + Returns: + If pagination is None: List[ChatLog] + If pagination is provided: PaginatedResult with items and metadata + """ + # Check workflow access first (without calling getWorkflow to avoid circular reference) + # Use RBAC filtering + workflows = getRecordsetWithRBAC(self.db, + ChatWorkflow, + self.currentUser, + recordFilter={"id": workflowId} + ) + + if not workflows: + if pagination is None: + return [] + return PaginatedResult(items=[], totalItems=0, totalPages=0) + + # Get logs for this workflow from normalized table + logs = getRecordsetWithRBAC(self.db, ChatLog, self.currentUser, recordFilter={"workflowId": workflowId}) + + # Convert raw logs to dict format for sorting/filtering + logDicts = [] + for log in logs: + logDicts.append({ + "id": log.get("id"), + "workflowId": log.get("workflowId"), + "message": log.get("message"), + "type": log.get("type"), + "timestamp": log.get("timestamp", getUtcTimestamp()), + "agentName": log.get("agentName"), + "status": log.get("status"), + "progress": log.get("progress"), + "mandateId": log.get("mandateId"), + "userId": log.get("userId") + }) + + # Apply default sorting by timestamp if no sort specified + if pagination is None or not pagination.sort: + logDicts.sort(key=lambda x: parseTimestamp(x.get("timestamp"), default=0)) + + # Apply filtering (if filters provided) + if pagination and pagination.filters: + logDicts = self._applyFilters(logDicts, pagination.filters) + + # Apply sorting (in order of sortFields) + if pagination and pagination.sort: + logDicts = self._applySorting(logDicts, pagination.sort) + + # If no pagination requested, return all items + if pagination is None: + return [ChatLog(**log) for log in logDicts] + + # Count total items after filters + totalItems = len(logDicts) + totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 + + # Apply pagination (skip/limit) + startIdx = (pagination.page - 1) * pagination.pageSize + endIdx = startIdx + pagination.pageSize + pagedLogDicts = logDicts[startIdx:endIdx] + + # Convert to model objects + items = [ChatLog(**log) for log in pagedLogDicts] + + return PaginatedResult( + items=items, + totalItems=totalItems, + totalPages=totalPages + ) + + def createLog(self, logData: Dict[str, Any]) -> ChatLog: + """Creates a log entry for a workflow if user has access.""" + # Check workflow access + workflowId = logData.get("workflowId") + if not workflowId: + logger.error("No workflowId provided for createLog") + return None + + workflow = self.getWorkflow(workflowId) + if not workflow: + logger.warning(f"No access to workflow {workflowId}") + return None + + if not self.checkRbacPermission(ChatWorkflow, "update", workflowId): + logger.warning(f"No permission to modify workflow {workflowId}") + return None + + # Make sure required fields are present + if "timestamp" not in logData: + logData["timestamp"] = getUtcTimestamp() + + # Set mandateId and featureInstanceId from context for proper data isolation + if "mandateId" not in logData or not logData["mandateId"]: + logData["mandateId"] = self.mandateId + if "featureInstanceId" not in logData or not logData["featureInstanceId"]: + logData["featureInstanceId"] = self.featureInstanceId + + # Add status information if not present + if "status" not in logData and "type" in logData: + if logData["type"] == "error": + logData["status"] = "error" + else: + logData["status"] = "running" + + # Add progress information if not present + if "progress" not in logData: + # Default progress values based on log type (0.0 to 1.0 format) + if logData.get("type") == "info": + logData["progress"] = 0.5 # Default middle progress + elif logData.get("type") == "error": + logData["progress"] = 1.0 # Error state - completed (failed) + elif logData.get("type") == "warning": + logData["progress"] = 0.5 # Default middle progress + + # Validate log data against ChatLog model + try: + log_model = ChatLog(**logData) + except Exception as e: + logger.error(f"Invalid log data: {str(e)}") + return None + + # Create log in normalized table + createdLog = self.db.recordCreate(ChatLog, log_model) + + # Emit log event for streaming (only for chatbot workflows) + # Only emit events for chatbot workflows, not for automation or dynamic workflows + if workflow.workflowMode == WorkflowModeEnum.WORKFLOW_CHATBOT: + try: + from modules.features.chatbot.eventManager import get_event_manager + event_manager = get_event_manager() + log_timestamp = parseTimestamp(createdLog.get("timestamp"), default=getUtcTimestamp()) + # Emit log event in exact chatData format: {type, createdAt, item} + asyncio.create_task(event_manager.emit_event( + workflowId, + "chatdata", + "New log", + "log", + { + "type": "log", + "createdAt": log_timestamp, + "item": ChatLog(**createdLog).model_dump() + } + )) + except Exception as e: + # Event manager not available or error - continue without emitting + logger.debug(f"Could not emit log event: {e}") + + # Return validated ChatLog instance + return ChatLog(**createdLog) + + # Stats methods + + def getStats(self, workflowId: str) -> List[ChatStat]: + """Returns list of statistics for a workflow if user has access.""" + # Check workflow access first (without calling getWorkflow to avoid circular reference) + # Use RBAC filtering + workflows = getRecordsetWithRBAC(self.db, + ChatWorkflow, + self.currentUser, + recordFilter={"id": workflowId} + ) + + if not workflows: + return [] + + # Get stats for this workflow from normalized table + stats = getRecordsetWithRBAC(self.db, ChatStat, self.currentUser, recordFilter={"workflowId": workflowId}) + + if not stats: + return [] + + # Return all stats records sorted by creation time + stats.sort(key=lambda x: x.get("created_at", "")) + return [ChatStat(**stat) for stat in stats] + + + def createStat(self, statData: Dict[str, Any]) -> ChatStat: + """Creates a new stats record and returns it.""" + try: + # Ensure workflowId is present in statData + if "workflowId" not in statData: + raise ValueError("workflowId is required in statData") + + # Set mandateId and featureInstanceId from context for proper data isolation + if "mandateId" not in statData or not statData["mandateId"]: + statData["mandateId"] = self.mandateId + if "featureInstanceId" not in statData or not statData["featureInstanceId"]: + statData["featureInstanceId"] = self.featureInstanceId + + # Validate the stat data against ChatStat model + stat = ChatStat(**statData) + + # Create the stat record in the database + created = self.db.recordCreate(ChatStat, stat) + + # Return the created ChatStat + return ChatStat(**created) + except Exception as e: + logger.error(f"Error creating workflow stat: {str(e)}") + raise + + + def getUnifiedChatData(self, workflowId: str, afterTimestamp: Optional[float] = None) -> Dict[str, Any]: + """ + Returns unified chat data (messages, logs, stats) for a workflow in chronological order. + Uses timestamp-based selective data transfer for efficient polling. + """ + # Check workflow access first + # Use RBAC filtering + workflows = getRecordsetWithRBAC(self.db, + ChatWorkflow, + self.currentUser, + recordFilter={"id": workflowId} + ) + + if not workflows: + return {"items": []} + + # Get all data types and filter in Python (PostgreSQL connector doesn't support $gt operators) + items = [] + + # Get messages + messages = getRecordsetWithRBAC(self.db, ChatMessage, self.currentUser, recordFilter={"workflowId": workflowId}) + for msg in messages: + # Apply timestamp filtering in Python + msgTimestamp = parseTimestamp(msg.get("publishedAt"), default=getUtcTimestamp()) + if afterTimestamp is not None and msgTimestamp <= afterTimestamp: + continue + + # Load documents for each message + documents = self.getDocuments(msg["id"]) + + # Create ChatMessage object with loaded documents + chatMessage = ChatMessage( + id=msg["id"], + workflowId=msg["workflowId"], + parentMessageId=msg.get("parentMessageId"), + documents=documents, + documentsLabel=msg.get("documentsLabel"), + message=msg.get("message"), + role=msg.get("role", "assistant"), + status=msg.get("status", "step"), + sequenceNr=msg.get("sequenceNr", 0), + publishedAt=msg.get("publishedAt", getUtcTimestamp()), + success=msg.get("success"), + actionId=msg.get("actionId"), + actionMethod=msg.get("actionMethod"), + actionName=msg.get("actionName"), + roundNumber=msg.get("roundNumber"), + taskNumber=msg.get("taskNumber"), + actionNumber=msg.get("actionNumber"), + taskProgress=msg.get("taskProgress"), + actionProgress=msg.get("actionProgress") + ) + + # Use publishedAt as the timestamp for chronological ordering + items.append({ + "type": "message", + "createdAt": msgTimestamp, + "item": chatMessage + }) + + # Get logs - return all logs with roundNumber if available + logs = getRecordsetWithRBAC(self.db, ChatLog, self.currentUser, recordFilter={"workflowId": workflowId}) + for log in logs: + # Apply timestamp filtering in Python + logTimestamp = parseTimestamp(log.get("timestamp"), default=getUtcTimestamp()) + if afterTimestamp is not None and logTimestamp <= afterTimestamp: + continue + + chatLog = ChatLog(**log) + items.append({ + "type": "log", + "createdAt": logTimestamp, + "item": chatLog + }) + + # Get stats list + stats = self.getStats(workflowId) + for stat in stats: + # Apply timestamp filtering in Python + stat_timestamp = stat.createdAt if hasattr(stat, 'createdAt') else getUtcTimestamp() + if afterTimestamp is not None and stat_timestamp <= afterTimestamp: + continue + + items.append({ + "type": "stat", + "createdAt": stat_timestamp, + "item": stat + }) + + # Sort all items by createdAt timestamp for chronological order + items.sort(key=lambda x: parseTimestamp(x.get("createdAt"), default=0)) + + return {"items": items} + + # ===== Automation Methods ===== + + def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str: + """Compute status field based on eventId presence""" + eventId = automation.get("eventId") + return "Running" if eventId else "Idle" + + def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Batch enrich automations with user names and mandate names for display. + Uses AppObjects interface to fetch users and mandates with proper access control. + """ + if not automations: + return automations + + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface + + # Collect all unique user IDs and mandate IDs + userIds = set() + mandateIds = set() + + for automation in automations: + createdBy = automation.get("_createdBy") + if createdBy: + userIds.add(createdBy) + + mandateId = automation.get("mandateId") + if mandateId: + mandateIds.add(mandateId) + + # Use AppObjects interface to fetch users (respects access control) + appInterface = getAppInterface(self.currentUser) + usersMap = {} + if userIds: + for user_id in userIds: + user = appInterface.getUser(user_id) + if user: + usersMap[user_id] = user.username or user.email or user_id + + # Use AppObjects interface to fetch mandates (respects access control) + mandatesMap = {} + if mandateIds: + for mandate_id in mandateIds: + mandate = appInterface.getMandate(mandate_id) + if mandate: + mandatesMap[mandate_id] = mandate.name or mandate_id + + # Enrich each automation with the fetched data + for automation in automations: + createdBy = automation.get("_createdBy") + if createdBy: + automation["_createdByUserName"] = usersMap.get(createdBy, createdBy) + else: + automation["_createdByUserName"] = "-" + + mandateId = automation.get("mandateId") + if mandateId: + automation["mandateName"] = mandatesMap.get(mandateId, mandateId) + else: + automation["mandateName"] = "-" + + return automations + + def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]: + """ + Enrich a single automation with user name and mandate name for display. + For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance. + """ + return self._enrichAutomationsWithUserAndMandate([automation])[0] + + def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: + """ + Returns automation definitions based on user access level. + Supports optional pagination, sorting, and filtering. + Computes status field for each automation. + """ + # Use RBAC filtering + filteredAutomations = getRecordsetWithRBAC(self.db, + AutomationDefinition, + self.currentUser + ) + + # Compute status for each automation and normalize executionLogs + for automation in filteredAutomations: + automation["status"] = self._computeAutomationStatus(automation) + # Ensure executionLogs is always a list, not None + if automation.get("executionLogs") is None: + automation["executionLogs"] = [] + + # Batch enrich with user and mandate names + self._enrichAutomationsWithUserAndMandate(filteredAutomations) + + # If no pagination requested, return all items + if pagination is None: + return filteredAutomations + + # Apply filtering (if filters provided) + if pagination.filters: + filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters) + + # Apply sorting (in order of sortFields) + if pagination.sort: + filteredAutomations = self._applySorting(filteredAutomations, pagination.sort) + + # Count total items after filters + totalItems = len(filteredAutomations) + totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0 + + # Apply pagination (skip/limit) + startIdx = (pagination.page - 1) * pagination.pageSize + endIdx = startIdx + pagination.pageSize + pagedAutomations = filteredAutomations[startIdx:endIdx] + + return PaginatedResult( + items=pagedAutomations, + totalItems=totalItems, + totalPages=totalPages + ) + + def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]: + """Returns an automation definition by ID if user has access, with computed status.""" + try: + # Use RBAC filtering + filtered = getRecordsetWithRBAC(self.db, + AutomationDefinition, + self.currentUser, + recordFilter={"id": automationId} + ) + + if not filtered: + return None + + automation = filtered[0] + automation["status"] = self._computeAutomationStatus(automation) + # Ensure executionLogs is always a list, not None + if automation.get("executionLogs") is None: + automation["executionLogs"] = [] + # Enrich with user and mandate names + self._enrichAutomationWithUserAndMandate(automation) + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) + except Exception as e: + logger.error(f"Error getting automation definition: {str(e)}") + return None + + def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition: + """Creates a new automation definition, then triggers sync.""" + try: + # Ensure ID is present + if "id" not in automationData or not automationData["id"]: + automationData["id"] = str(uuid.uuid4()) + + # Ensure mandateId and featureInstanceId are set for proper data isolation + if "mandateId" not in automationData: + automationData["mandateId"] = self.mandateId + if "featureInstanceId" not in automationData: + automationData["featureInstanceId"] = self.featureInstanceId + + # Ensure database connector has correct userId context + # The connector should have been initialized with userId, but ensure it's updated + if self.userId and hasattr(self.db, 'updateContext'): + try: + self.db.updateContext(self.userId) + except Exception as e: + logger.warning(f"Could not update database context: {e}") + + # Note: _createdBy will be set automatically by connector's _saveRecord method + # when _createdAt is not present. We don't need to set it manually here. + # Use generic field separation + simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) + + # Create automation in database + createdAutomation = self.db.recordCreate(AutomationDefinition, simpleFields) + + # Compute status + createdAutomation["status"] = self._computeAutomationStatus(createdAutomation) + # Ensure executionLogs is always a list, not None + if createdAutomation.get("executionLogs") is None: + createdAutomation["executionLogs"] = [] + + # Trigger automation change callback (async, don't wait) + asyncio.create_task(self._notifyAutomationChanged()) + + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) + except Exception as e: + logger.error(f"Error creating automation definition: {str(e)}") + raise + + def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition: + """Updates an automation definition, then triggers sync.""" + try: + # Check access + existing = self.getAutomationDefinition(automationId) + if not existing: + raise PermissionError(f"No access to automation {automationId}") + + if not self.checkRbacPermission(AutomationDefinition, "update", automationId): + raise PermissionError(f"No permission to modify automation {automationId}") + + # Use generic field separation + simpleFields, objectFields = self._separateObjectFields(AutomationDefinition, automationData) + + # Update automation in database + updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, simpleFields) + + # Compute status + updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation) + # Ensure executionLogs is always a list, not None + if updatedAutomation.get("executionLogs") is None: + updatedAutomation["executionLogs"] = [] + + # Trigger automation change callback (async, don't wait) + asyncio.create_task(self._notifyAutomationChanged()) + + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) + except Exception as e: + logger.error(f"Error updating automation definition: {str(e)}") + raise + + def deleteAutomationDefinition(self, automationId: str) -> bool: + """Deletes an automation definition, then triggers sync.""" + try: + # Check access + existing = self.getAutomationDefinition(automationId) + if not existing: + raise PermissionError(f"No access to automation {automationId}") + + if not self.checkRbacPermission(AutomationDefinition, "delete", automationId): + raise PermissionError(f"No permission to delete automation {automationId}") + + # Remove event if exists + if existing.get("eventId"): + from modules.shared.eventManagement import eventManager + try: + eventManager.remove(existing["eventId"]) + except Exception as e: + logger.warning(f"Error removing event {existing['eventId']}: {str(e)}") + + # Delete automation from database + self.db.recordDelete(AutomationDefinition, automationId) + + # Trigger automation change callback (async, don't wait) + asyncio.create_task(self._notifyAutomationChanged()) + + return True + except Exception as e: + logger.error(f"Error deleting automation definition: {str(e)}") + raise + + def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]: + """ + Get all automation definitions filtered by RBAC for a specific user. + This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector. + + Args: + user: User object for RBAC filtering + + Returns: + List of automation definition dictionaries filtered by RBAC + """ + return getRecordsetWithRBAC( + self.db, + AutomationDefinition, + user + ) + + async def _notifyAutomationChanged(self): + """Notify registered callbacks about automation changes (decoupled from features).""" + try: + from modules.shared.callbackRegistry import callbackRegistry + # Trigger callbacks without knowing which features are listening + await callbackRegistry.trigger('automation.changed', self) + except Exception as e: + logger.error(f"Error notifying automation change: {str(e)}") + + +def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects': + """ + Returns a ChatObjects instance for the current user. + Handles initialization of database and records. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. + featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header). + """ + if not currentUser: + raise ValueError("Invalid user context: user is required") + + effectiveMandateId = str(mandateId) if mandateId else None + effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None + + # Create context key including featureInstanceId for proper isolation + contextKey = f"{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}" + + # Create new instance if not exists + if contextKey not in _chatInterfaces: + _chatInterfaces[contextKey] = ChatObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) + else: + # Update user context if needed + _chatInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) + + return _chatInterfaces[contextKey] diff --git a/modules/interfaces/interfaceDbChatbot.py b/modules/interfaces/interfaceDbChatbot.py index c9f87a55..44f124b5 100644 --- a/modules/interfaces/interfaceDbChatbot.py +++ b/modules/interfaces/interfaceDbChatbot.py @@ -1,8 +1,8 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Interface to LucyDOM database and AI Connectors. -Uses the JSON connector for data access with added language support. +Interface to Chatbot database and AI Connectors. +Uses the PostgreSQL connector for data access with user/mandate filtering. """ import logging @@ -59,7 +59,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None: import os from datetime import datetime, UTC from modules.shared.debugLogger import _getBaseDebugDir, _ensureDir - from modules.interfaces.interfaceDbComponentObjects import getInterface + from modules.interfaces.interfaceDbManagement import getInterface # Create base debug directory (use base debug dir, not prompts subdirectory) baseDebugDir = _getBaseDebugDir() @@ -314,7 +314,7 @@ class ChatObjects: try: # Get configuration values with defaults dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") - dbDatabase = "poweron_chat" + dbDatabase = "poweron_chatbot" dbUser = APP_CONFIG.get("DB_USER") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) @@ -1668,7 +1668,7 @@ class ChatObjects: if not automations: return automations - from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface # Collect all unique user IDs and mandate IDs userIds = set() diff --git a/modules/interfaces/interfaceDbComponentObjects.py b/modules/interfaces/interfaceDbManagement.py similarity index 99% rename from modules/interfaces/interfaceDbComponentObjects.py rename to modules/interfaces/interfaceDbManagement.py index 72d65839..b514cbf3 100644 --- a/modules/interfaces/interfaceDbComponentObjects.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -215,7 +215,7 @@ class ComponentObjects: # Get the root interface to access the initial mandate ID from modules.security.rootAccess import getRootUser - from modules.interfaces.interfaceDbAppObjects import getInterface + from modules.interfaces.interfaceDbApp import getInterface rootUser = getRootUser() rootInterface = getInterface(rootUser) diff --git a/modules/routes/routeAdmin.py b/modules/routes/routeAdmin.py index a60527e1..878dbd66 100644 --- a/modules/routes/routeAdmin.py +++ b/modules/routes/routeAdmin.py @@ -12,7 +12,7 @@ from fastapi import HTTPException, status from modules.shared.configuration import APP_CONFIG from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface # Static folder setup - using absolute path from app root baseDir = FilePath(__file__).parent.parent.parent # Go up to gateway root diff --git a/modules/routes/routeFeatureWorkflow.py b/modules/routes/routeAdminAutomationEvents.py similarity index 94% rename from modules/routes/routeFeatureWorkflow.py rename to modules/routes/routeAdminAutomationEvents.py index 33b9c0fc..c62977aa 100644 --- a/modules/routes/routeFeatureWorkflow.py +++ b/modules/routes/routeAdminAutomationEvents.py @@ -11,7 +11,7 @@ from fastapi import status import logging # Import interfaces and models -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot +import modules.interfaces.interfaceDbChat as interfaceDbChat from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext from modules.datamodels.datamodelUam import User @@ -76,8 +76,8 @@ async def sync_all_automation_events( This will register/remove events based on active flags. """ try: - from modules.interfaces.interfaceDbChatbot import getInterface as getChatInterface - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbChat import getInterface as getChatInterface + from modules.interfaces.interfaceDbApp import getRootInterface from modules.features.workflow import syncAutomationEvents chatInterface = getChatInterface(currentUser) @@ -127,7 +127,7 @@ async def remove_event( # Update automation's eventId if it exists if eventId.startswith("automation."): automation_id = eventId.replace("automation.", "") - chatInterface = interfaceDbChatbot.getInterface(currentUser) + chatInterface = interfaceDbChat.getInterface(currentUser) automation = chatInterface.getAutomationDefinition(automation_id) if automation and getattr(automation, "eventId", None) == eventId: chatInterface.updateAutomationDefinition(automation_id, {"eventId": None}) diff --git a/modules/routes/routeFeatures.py b/modules/routes/routeAdminFeatures.py similarity index 99% rename from modules/routes/routeFeatures.py rename to modules/routes/routeAdminFeatures.py index 0659d919..d62a48e1 100644 --- a/modules/routes/routeFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -19,7 +19,7 @@ from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelFeatures import Feature, FeatureInstance -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface logger = logging.getLogger(__name__) diff --git a/modules/routes/routeRbacExport.py b/modules/routes/routeAdminRbacExport.py similarity index 99% rename from modules/routes/routeRbacExport.py rename to modules/routes/routeAdminRbacExport.py index e7cc7204..11932f18 100644 --- a/modules/routes/routeRbacExport.py +++ b/modules/routes/routeAdminRbacExport.py @@ -21,7 +21,7 @@ from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelRbac import Role, AccessRule -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp logger = logging.getLogger(__name__) diff --git a/modules/routes/routeAdminRbacRoles.py b/modules/routes/routeAdminRbacRoles.py index b7069f86..c5c45963 100644 --- a/modules/routes/routeAdminRbacRoles.py +++ b/modules/routes/routeAdminRbacRoles.py @@ -17,7 +17,7 @@ from modules.auth import limiter, requireSysAdmin from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole -from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface +from modules.interfaces.interfaceDbApp import getInterface, getRootInterface # Configure logger logger = logging.getLogger(__name__) diff --git a/modules/routes/routeRbac.py b/modules/routes/routeAdminRbacRules.py similarity index 99% rename from modules/routes/routeRbac.py rename to modules/routes/routeAdminRbacRules.py index 7ab9b229..f16a9bc7 100644 --- a/modules/routes/routeRbac.py +++ b/modules/routes/routeAdminRbacRules.py @@ -20,7 +20,7 @@ from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestCon from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict -from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface +from modules.interfaces.interfaceDbApp import getInterface, getRootInterface # Configure logger logger = logging.getLogger(__name__) diff --git a/modules/routes/routeDataAutomation.py b/modules/routes/routeDataAutomation.py index 3a3e3ab4..db6affab 100644 --- a/modules/routes/routeDataAutomation.py +++ b/modules/routes/routeDataAutomation.py @@ -13,7 +13,7 @@ import logging import json # Import interfaces and models -from modules.interfaces.interfaceDbChatbot import getInterface as getChatInterface +from modules.interfaces.interfaceDbChat import getInterface as getChatInterface from modules.auth import getCurrentUser, limiter from modules.datamodels.datamodelChat import AutomationDefinition, ChatWorkflow from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index ea660554..f39b6638 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -21,9 +21,9 @@ from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, from modules.datamodels.datamodelSecurity import Token from modules.auth import getCurrentUser, limiter from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict -from modules.interfaces.interfaceDbAppObjects import getInterface +from modules.interfaces.interfaceDbApp import getInterface from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp -from modules.interfaces.interfaceDbComponentObjects import ComponentObjects +from modules.interfaces.interfaceDbManagement import ComponentObjects # Configure logger logger = logging.getLogger(__name__) diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 23db7170..66345ce5 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -10,7 +10,7 @@ import json from modules.auth import limiter, getCurrentUser # Import interfaces -import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects +import modules.interfaces.interfaceDbManagement as interfaceDbManagement from modules.datamodels.datamodelFiles import FileItem, FilePreview from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.datamodels.datamodelUam import User @@ -69,7 +69,7 @@ async def get_files( detail=f"Invalid pagination parameter: {str(e)}" ) - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) result = managementInterface.getAllFiles(pagination=paginationParams) # If pagination was requested, result is PaginatedResult @@ -112,17 +112,17 @@ async def upload_file( file.fileName = file.filename """Upload a file""" try: - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Read file fileContent = await file.read() # Check size limits - maxSize = int(interfaceDbComponentObjects.APP_CONFIG.get("File_Management_MAX_UPLOAD_SIZE_MB")) * 1024 * 1024 # in bytes + maxSize = int(interfaceDbManagement.APP_CONFIG.get("File_Management_MAX_UPLOAD_SIZE_MB")) * 1024 * 1024 # in bytes if len(fileContent) > maxSize: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail=f"File too large. Maximum size: {interfaceDbComponentObjects.APP_CONFIG.get('File_Management_MAX_UPLOAD_SIZE_MB')}MB" + detail=f"File too large. Maximum size: {interfaceDbManagement.APP_CONFIG.get('File_Management_MAX_UPLOAD_SIZE_MB')}MB" ) # Save file via LucyDOM interface in the database @@ -155,7 +155,7 @@ async def upload_file( "isDuplicate": duplicateType != "new_file" }) - except interfaceDbComponentObjects.FileStorageError as e: + except interfaceDbManagement.FileStorageError as e: logger.error(f"Error during file upload (storage): {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -177,7 +177,7 @@ async def get_file( ) -> FileItem: """Get a file""" try: - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Get file via LucyDOM interface from the database fileData = managementInterface.getFile(fileId) @@ -189,19 +189,19 @@ async def get_file( return fileData - except interfaceDbComponentObjects.FileNotFoundError as e: + except interfaceDbManagement.FileNotFoundError as e: logger.warning(f"File not found: {str(e)}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(e) ) - except interfaceDbComponentObjects.FilePermissionError as e: + except interfaceDbManagement.FilePermissionError as e: logger.warning(f"No permission for file: {str(e)}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(e) ) - except interfaceDbComponentObjects.FileError as e: + except interfaceDbManagement.FileError as e: logger.error(f"Error retrieving file: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -224,7 +224,7 @@ async def update_file( ) -> FileItem: """Update file info""" try: - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Get the file from the database file = managementInterface.getFile(fileId) @@ -270,7 +270,7 @@ async def delete_file( currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: """Delete a file""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Check if the file exists existingFile = managementInterface.getFile(fileId) @@ -297,7 +297,7 @@ async def get_file_stats( ) -> Dict[str, Any]: """Returns statistics about the stored files""" try: - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Get all files - metadata only allFiles = managementInterface.getAllFiles() @@ -336,7 +336,7 @@ async def download_file( ) -> Response: """Download a file""" try: - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Get file data fileData = managementInterface.getFile(fileId) @@ -384,7 +384,7 @@ async def preview_file( ) -> FilePreview: """Preview a file's content""" try: - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Get file preview using the correct method preview = managementInterface.getFileContent(fileId) diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 82ed3ad6..ab0ad8c1 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -20,7 +20,7 @@ from pydantic import BaseModel, Field from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext # Import interfaces -import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects +import modules.interfaces.interfaceDbApp as interfaceDbApp from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.auditLogger import audit_logger @@ -107,7 +107,7 @@ async def get_mandates( detail=f"Invalid pagination parameter: {str(e)}" ) - appInterface = interfaceDbAppObjects.getRootInterface() + appInterface = interfaceDbApp.getRootInterface() result = appInterface.getAllMandates(pagination=paginationParams) # If pagination was requested, result is PaginatedResult @@ -150,7 +150,7 @@ async def get_mandate( MULTI-TENANT: SysAdmin-only. """ try: - appInterface = interfaceDbAppObjects.getRootInterface() + appInterface = interfaceDbApp.getRootInterface() mandate = appInterface.getMandate(mandateId) if not mandate: @@ -195,7 +195,7 @@ async def create_mandate( description = mandateData.get('description') enabled = mandateData.get('enabled', True) - appInterface = interfaceDbAppObjects.getRootInterface() + appInterface = interfaceDbApp.getRootInterface() # Create mandate newMandate = appInterface.createMandate( @@ -237,7 +237,7 @@ async def update_mandate( try: logger.debug(f"Updating mandate {mandateId} with data: {mandateData}") - appInterface = interfaceDbAppObjects.getRootInterface() + appInterface = interfaceDbApp.getRootInterface() # Check if mandate exists existingMandate = appInterface.getMandate(mandateId) @@ -280,7 +280,7 @@ async def delete_mandate( MULTI-TENANT: SysAdmin-only. """ try: - appInterface = interfaceDbAppObjects.getRootInterface() + appInterface = interfaceDbApp.getRootInterface() # Check if mandate exists existingMandate = appInterface.getMandate(mandateId) @@ -347,7 +347,7 @@ async def listMandateUsers( ) try: - rootInterface = interfaceDbAppObjects.getRootInterface() + rootInterface = interfaceDbApp.getRootInterface() # Verify mandate exists mandate = rootInterface.getMandate(targetMandateId) @@ -518,7 +518,7 @@ async def addUserToMandate( ) try: - rootInterface = interfaceDbAppObjects.getRootInterface() + rootInterface = interfaceDbApp.getRootInterface() # 3. Verify mandate exists mandate = rootInterface.getMandate(targetMandateId) @@ -627,7 +627,7 @@ async def removeUserFromMandate( ) try: - rootInterface = interfaceDbAppObjects.getRootInterface() + rootInterface = interfaceDbApp.getRootInterface() # Verify mandate exists mandate = rootInterface.getMandate(targetMandateId) @@ -707,7 +707,7 @@ async def updateUserRolesInMandate( ) try: - rootInterface = interfaceDbAppObjects.getRootInterface() + rootInterface = interfaceDbApp.getRootInterface() # Get user's membership membership = rootInterface.getUserMandate(targetUserId, targetMandateId) @@ -810,7 +810,7 @@ def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool: return False try: - rootInterface = interfaceDbAppObjects.getRootInterface() + rootInterface = interfaceDbApp.getRootInterface() for roleId in context.roleIds: roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index d3736b75..48902e66 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -10,7 +10,7 @@ import json from modules.auth import limiter, getCurrentUser # Import interfaces -import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects +import modules.interfaces.interfaceDbManagement as interfaceDbManagement from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict @@ -58,7 +58,7 @@ async def get_prompts( detail=f"Invalid pagination parameter: {str(e)}" ) - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) result = managementInterface.getAllPrompts(pagination=paginationParams) # If pagination was requested, result is PaginatedResult @@ -89,7 +89,7 @@ async def create_prompt( currentUser: User = Depends(getCurrentUser) ) -> Prompt: """Create a new prompt""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Create prompt newPrompt = managementInterface.createPrompt(prompt) @@ -104,7 +104,7 @@ async def get_prompt( currentUser: User = Depends(getCurrentUser) ) -> Prompt: """Get a specific prompt""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Get prompt prompt = managementInterface.getPrompt(promptId) @@ -125,7 +125,7 @@ async def update_prompt( currentUser: User = Depends(getCurrentUser) ) -> Prompt: """Update an existing prompt""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Check if the prompt exists existingPrompt = managementInterface.getPrompt(promptId) @@ -160,7 +160,7 @@ async def delete_prompt( currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: """Delete a prompt""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) # Check if the prompt exists existingPrompt = managementInterface.getPrompt(promptId) diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 62a9b344..dab8bde9 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -17,7 +17,7 @@ import logging import json # Import interfaces and models -import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects +import modules.interfaces.interfaceDbApp as interfaceDbApp from modules.auth import limiter, getRequestContext, RequestContext # Import the attribute definition and helper functions @@ -179,7 +179,7 @@ async def get_users( detail=f"Invalid pagination parameter: {str(e)}" ) - appInterface = interfaceDbAppObjects.getInterface(context.user) + appInterface = interfaceDbApp.getInterface(context.user) # MULTI-TENANT: Use mandateId from context (header) # SysAdmin without mandateId can see all users @@ -278,7 +278,7 @@ async def get_user( MULTI-TENANT: User must be in the same mandate (via UserMandate) or caller is SysAdmin. """ try: - appInterface = interfaceDbAppObjects.getInterface(context.user) + appInterface = interfaceDbApp.getInterface(context.user) # Get user without filtering by enabled status user = appInterface.getUser(userId) @@ -333,7 +333,7 @@ async def create_user( Create a new user. MULTI-TENANT: User is created and automatically added to the current mandate. """ - appInterface = interfaceDbAppObjects.getInterface(context.user) + appInterface = interfaceDbApp.getInterface(context.user) # Extract fields from request model and call createUser with individual parameters from modules.datamodels.datamodelUam import AuthAuthority @@ -375,7 +375,7 @@ async def update_user( Update an existing user. MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin). """ - appInterface = interfaceDbAppObjects.getInterface(context.user) + appInterface = interfaceDbApp.getInterface(context.user) # Check if the user exists existingUser = appInterface.getUser(userId) @@ -430,7 +430,7 @@ async def reset_user_password( ) # Get user interface - appInterface = interfaceDbAppObjects.getInterface(context.user) + appInterface = interfaceDbApp.getInterface(context.user) # Get target user target_user = appInterface.getUser(userId) @@ -525,7 +525,7 @@ async def change_password( """ try: # Get user interface - appInterface = interfaceDbAppObjects.getInterface(context.user) + appInterface = interfaceDbApp.getInterface(context.user) # Verify current password if not appInterface.verifyPassword(currentPassword, context.user.passwordHash): @@ -612,10 +612,10 @@ async def sendPasswordLink( """ try: from modules.shared.configuration import APP_CONFIG - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface # Get user interface - appInterface = interfaceDbAppObjects.getInterface(context.user) + appInterface = interfaceDbApp.getInterface(context.user) # Get target user targetUser = appInterface.getUser(userId) @@ -742,7 +742,7 @@ async def delete_user( Delete a user. MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin). """ - appInterface = interfaceDbAppObjects.getInterface(context.user) + appInterface = interfaceDbApp.getInterface(context.user) # Check if the user exists existingUser = appInterface.getUser(userId) diff --git a/modules/routes/routeWorkflows.py b/modules/routes/routeDataWorkflows.py similarity index 99% rename from modules/routes/routeWorkflows.py rename to modules/routes/routeDataWorkflows.py index ab9e1ff6..d3b2d825 100644 --- a/modules/routes/routeWorkflows.py +++ b/modules/routes/routeDataWorkflows.py @@ -14,8 +14,8 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon from modules.auth import limiter, getCurrentUser # Import interfaces -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot -from modules.interfaces.interfaceDbChatbot import getInterface +import modules.interfaces.interfaceDbChat as interfaceDbChat +from modules.interfaces.interfaceDbChat import getInterface from modules.interfaces.interfaceRbac import getRecordsetWithRBAC # Import models @@ -45,7 +45,7 @@ router = APIRouter( ) def getServiceChat(currentUser: User): - return interfaceDbChatbot.getInterface(currentUser) + return interfaceDbChat.getInterface(currentUser) # Consolidated endpoint for getting all workflows @router.get("/", response_model=PaginatedResponse[ChatWorkflow]) diff --git a/modules/routes/routeFeatureChatDynamic.py b/modules/routes/routeFeatureChatDynamic.py index ed1fd9f3..5be544a8 100644 --- a/modules/routes/routeFeatureChatDynamic.py +++ b/modules/routes/routeFeatureChatDynamic.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Reques from modules.auth import limiter, getRequestContext, RequestContext # Import interfaces -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot +import modules.interfaces.interfaceDbChat as interfaceDbChat # Import models from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum @@ -32,7 +32,7 @@ router = APIRouter( ) def _getServiceChat(context: RequestContext): - return interfaceDbChatbot.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) + return interfaceDbChat.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) # Workflow start endpoint @router.post("/start", response_model=ChatWorkflow) diff --git a/modules/routes/routeFeatureChatbot.py b/modules/routes/routeFeatureChatbot.py index b5b80e2e..977158f0 100644 --- a/modules/routes/routeFeatureChatbot.py +++ b/modules/routes/routeFeatureChatbot.py @@ -18,7 +18,7 @@ from modules.shared.timeUtils import parseTimestamp from modules.auth import limiter, getRequestContext, RequestContext # Import interfaces -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot +import modules.interfaces.interfaceDbChat as interfaceDbChat from modules.interfaces.interfaceRbac import getRecordsetWithRBAC # Import models @@ -43,7 +43,7 @@ router = APIRouter( ) def _getServiceChat(context: RequestContext): - return interfaceDbChatbot.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) + return interfaceDbChat.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) # Chatbot streaming endpoint (SSE) @router.post("/start/stream") diff --git a/modules/routes/routeFeatureRealEstate.py b/modules/routes/routeFeatureRealEstate.py index 7e130c1b..73364345 100644 --- a/modules/routes/routeFeatureRealEstate.py +++ b/modules/routes/routeFeatureRealEstate.py @@ -26,7 +26,7 @@ from modules.datamodels.datamodelRealEstate import ( ) # Import interfaces -from modules.interfaces.interfaceDbRealEstate import getInterface as getRealEstateInterface +from modules.interfaces.interfaceDbRealestate import getInterface as getRealEstateInterface # Import feature logic for AI-powered commands from modules.features.realEstate.mainRealEstate import ( diff --git a/modules/routes/routeFeatureTrustee.py b/modules/routes/routeFeatureTrustee.py index ad842db8..14b0a9e8 100644 --- a/modules/routes/routeFeatureTrustee.py +++ b/modules/routes/routeFeatureTrustee.py @@ -19,7 +19,7 @@ import io from modules.auth import limiter, getRequestContext, RequestContext from modules.interfaces.interfaceDbTrustee import getInterface -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.datamodels.datamodelTrustee import ( TrusteeOrganisation, diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py index 53375849..f99dcd77 100644 --- a/modules/routes/routeGdpr.py +++ b/modules/routes/routeGdpr.py @@ -21,7 +21,7 @@ from pydantic import BaseModel, Field from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp from modules.shared.auditLogger import audit_logger diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index f649d2b2..3b059f9c 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -19,7 +19,7 @@ from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelInvitation import Invitation -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp logger = logging.getLogger(__name__) diff --git a/modules/routes/routeMessaging.py b/modules/routes/routeMessaging.py index dae6e04e..953dd5f2 100644 --- a/modules/routes/routeMessaging.py +++ b/modules/routes/routeMessaging.py @@ -11,7 +11,7 @@ from modules.auth import limiter, getCurrentUser, getRequestContext, RequestCont from modules.datamodels.datamodelRbac import Role # Import interfaces -import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects +import modules.interfaces.interfaceDbManagement as interfaceDbManagement from modules.datamodels.datamodelMessaging import ( MessagingSubscription, MessagingSubscriptionRegistration, @@ -55,7 +55,7 @@ async def getSubscriptions( detail=f"Invalid pagination parameter: {str(e)}" ) - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) result = managementInterface.getAllSubscriptions(pagination=paginationParams) if paginationParams: @@ -85,7 +85,7 @@ async def createSubscription( currentUser: User = Depends(getCurrentUser) ) -> MessagingSubscription: """Create a new subscription""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) subscriptionData = subscription.model_dump(exclude={"id"}) newSubscription = managementInterface.createSubscription(subscriptionData) @@ -101,7 +101,7 @@ async def getSubscription( currentUser: User = Depends(getCurrentUser) ) -> MessagingSubscription: """Get a specific subscription""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) subscription = managementInterface.getSubscription(subscriptionId) if not subscription: @@ -122,7 +122,7 @@ async def updateSubscription( currentUser: User = Depends(getCurrentUser) ) -> MessagingSubscription: """Update an existing subscription""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) existingSubscription = managementInterface.getSubscription(subscriptionId) if not existingSubscription: @@ -151,7 +151,7 @@ async def deleteSubscription( currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: """Delete a subscription""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) existingSubscription = managementInterface.getSubscription(subscriptionId) if not existingSubscription: @@ -192,7 +192,7 @@ async def getSubscriptionRegistrations( detail=f"Invalid pagination parameter: {str(e)}" ) - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) result = managementInterface.getAllRegistrations( subscriptionId=subscriptionId, pagination=paginationParams @@ -227,7 +227,7 @@ async def subscribeUser( currentUser: User = Depends(getCurrentUser) ) -> MessagingSubscriptionRegistration: """Subscribe user to a subscription with a specific channel""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) registration = managementInterface.subscribeUser( subscriptionId=subscriptionId, @@ -248,7 +248,7 @@ async def unsubscribeUser( currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: """Unsubscribe user from a subscription for a specific channel""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) success = managementInterface.unsubscribeUser( subscriptionId=subscriptionId, @@ -284,7 +284,7 @@ async def getMyRegistrations( detail=f"Invalid pagination parameter: {str(e)}" ) - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) result = managementInterface.getAllRegistrations( userId=currentUser.id, pagination=paginationParams @@ -318,7 +318,7 @@ async def updateRegistration( currentUser: User = Depends(getCurrentUser) ) -> MessagingSubscriptionRegistration: """Update a registration""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) existingRegistration = managementInterface.getRegistration(registrationId) if not existingRegistration: @@ -347,7 +347,7 @@ async def deleteRegistration( currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: """Delete a registration""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) existingRegistration = managementInterface.getRegistration(registrationId) if not existingRegistration: @@ -417,7 +417,7 @@ def _hasTriggerPermission(context: RequestContext) -> bool: return False try: - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() for roleId in context.roleIds: @@ -458,7 +458,7 @@ async def getDeliveries( detail=f"Invalid pagination parameter: {str(e)}" ) - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) result = managementInterface.getDeliveries( subscriptionId=subscriptionId, userId=currentUser.id, # Users can only see their own deliveries @@ -492,7 +492,7 @@ async def getDelivery( currentUser: User = Depends(getCurrentUser) ) -> MessagingDelivery: """Get a specific delivery""" - managementInterface = interfaceDbComponentObjects.getInterface(currentUser) + managementInterface = interfaceDbManagement.getInterface(currentUser) delivery = managementInterface.getDelivery(deliveryId) if not delivery: diff --git a/modules/routes/routeSecurityAdmin.py b/modules/routes/routeSecurityAdmin.py index 3ba35e65..36388c9f 100644 --- a/modules/routes/routeSecurityAdmin.py +++ b/modules/routes/routeSecurityAdmin.py @@ -13,7 +13,7 @@ import logging from modules.auth import getCurrentUser, limiter, requireSysAdmin from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelSecurity import Token from modules.shared.configuration import APP_CONFIG diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 2a8f65fd..9642df00 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -13,7 +13,7 @@ from requests_oauthlib import OAuth2Session import httpx from modules.shared.configuration import APP_CONFIG -from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface +from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection from modules.auth import getCurrentUser, limiter from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 96f22136..64984eef 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -16,7 +16,7 @@ from jose import jwt # Import auth modules from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie -from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface +from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelSecurity import Token from modules.shared.configuration import APP_CONFIG diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index 77dc9885..6d034607 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -13,7 +13,7 @@ import msal import httpx from modules.shared.configuration import APP_CONFIG -from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface +from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection from modules.datamodels.datamodelSecurity import Token from modules.auth import getCurrentUser, limiter diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py index 7915b0f1..aa62afc6 100644 --- a/modules/routes/routeSharepoint.py +++ b/modules/routes/routeSharepoint.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Path, Query, Request, sta from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User, UserConnection -from modules.interfaces.interfaceDbAppObjects import getInterface +from modules.interfaces.interfaceDbApp import getInterface from modules.services import getInterface as getServices logger = logging.getLogger(__name__) diff --git a/modules/services/__init__.py b/modules/services/__init__.py index fb4e6512..ff91dc6b 100644 --- a/modules/services/__init__.py +++ b/modules/services/__init__.py @@ -49,13 +49,13 @@ class Services: # Initialize interfaces with explicit mandateId - from modules.interfaces.interfaceDbChatbot import getInterface as getChatInterface + from modules.interfaces.interfaceDbChat import getInterface as getChatInterface self.interfaceDbChat = getChatInterface(user, mandateId=mandateId) - from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface self.interfaceDbApp = getAppInterface(user, mandateId=mandateId) - from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface + from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId) from modules.interfaces.interfaceDbTrustee import getInterface as getTrusteeInterface diff --git a/modules/services/serviceExtraction/mainServiceExtraction.py b/modules/services/serviceExtraction/mainServiceExtraction.py index 13739dea..199201eb 100644 --- a/modules/services/serviceExtraction/mainServiceExtraction.py +++ b/modules/services/serviceExtraction/mainServiceExtraction.py @@ -65,7 +65,7 @@ class ExtractionService: results: List[ContentExtracted] = [] # Lazy import to avoid circular deps and heavy init at module import - from modules.interfaces.interfaceDbComponentObjects import getInterface + from modules.interfaces.interfaceDbManagement import getInterface dbInterface = getInterface() totalDocs = len(documents) diff --git a/modules/services/serviceUtils/mainServiceUtils.py b/modules/services/serviceUtils/mainServiceUtils.py index 5d3a8497..2c975f1d 100644 --- a/modules/services/serviceUtils/mainServiceUtils.py +++ b/modules/services/serviceUtils/mainServiceUtils.py @@ -157,11 +157,11 @@ class UtilsService: def storeDebugMessageAndDocuments(self, message, currentUser): """ - Wrapper to store debug messages and documents via interfaceDbChatbot. - Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChatbot. + Wrapper to store debug messages and documents via interfaceDbChat. + Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChat. """ try: - from modules.interfaces.interfaceDbChatbot import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments + from modules.interfaces.interfaceDbChat import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments _storeDebugMessageAndDocuments(message, currentUser) except Exception: # Silent fail to never break main flow diff --git a/modules/workflows/processing/shared/placeholderFactory.py b/modules/workflows/processing/shared/placeholderFactory.py index a6e3a78a..136dd2cb 100644 --- a/modules/workflows/processing/shared/placeholderFactory.py +++ b/modules/workflows/processing/shared/placeholderFactory.py @@ -452,10 +452,10 @@ def extractLatestRefinementFeedback(context: Any) -> str: # First check for ERROR level logs in workflow if hasattr(context, 'workflow') and context.workflow: try: - import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot - from modules.interfaces.interfaceDbAppObjects import getRootInterface + import modules.interfaces.interfaceDbChat as interfaceDbChat + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() - interfaceDbChat = interfaceDbChatbot.getInterface(rootInterface.currentUser) + interfaceDbChat = interfaceDbChat.getInterface(rootInterface.currentUser) # Get workflow logs chatData = interfaceDbChat.getUnifiedChatData(context.workflow.id, None) diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py index 05a3f34b..f807fe24 100644 --- a/tests/functional/test03_ai_operations.py +++ b/tests/functional/test03_ai_operations.py @@ -27,7 +27,7 @@ class MethodAiOperationsTester: def __init__(self): # Use root user for testing (has full access to everything) - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() self.testUser = rootInterface.currentUser @@ -94,8 +94,8 @@ class MethodAiOperationsTester: logging.getLogger().setLevel(logging.DEBUG) # Import and initialize services - use the same approach as routeChatPlayground - import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + import modules.interfaces.interfaceDbChat as interfaceDbChat + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) # Import and initialize services from modules.services import getInterface as getServices @@ -201,8 +201,8 @@ class MethodAiOperationsTester: # Save message to database if self.services.workflow: - import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + import modules.interfaces.interfaceDbChat as interfaceDbChat + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) messageDict = testMessage.model_dump() interfaceDbChat.createMessage(messageDict) @@ -283,8 +283,8 @@ class MethodAiOperationsTester: maxSteps=5 ) # Save workflow to database - import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + import modules.interfaces.interfaceDbChat as interfaceDbChat + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) workflowDict = testWorkflow.model_dump() interfaceDbChat.createWorkflow(workflowDict) diff --git a/tests/functional/test04_ai_behavior.py b/tests/functional/test04_ai_behavior.py index 9da22d66..3e46bc0c 100644 --- a/tests/functional/test04_ai_behavior.py +++ b/tests/functional/test04_ai_behavior.py @@ -27,7 +27,7 @@ from modules.datamodels.datamodelWorkflow import AiResponse class AIBehaviorTester: def __init__(self): # Use root user for testing (has full access to everything) - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() self.testUser = rootInterface.currentUser @@ -45,7 +45,7 @@ class AIBehaviorTester: from modules.datamodels.datamodelChat import ChatWorkflow, WorkflowModeEnum import uuid import time - import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot + import modules.interfaces.interfaceDbChat as interfaceDbChat currentTimestamp = time.time() @@ -67,7 +67,7 @@ class AIBehaviorTester: ) # SAVE workflow to database so it exists for access control - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) workflowDict = testWorkflow.model_dump() interfaceDbChat.createWorkflow(workflowDict) diff --git a/tests/functional/test05_workflow_with_documents.py b/tests/functional/test05_workflow_with_documents.py index 7beaeec4..8850ae2b 100644 --- a/tests/functional/test05_workflow_with_documents.py +++ b/tests/functional/test05_workflow_with_documents.py @@ -23,13 +23,13 @@ from modules.services import getInterface as getServices from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelUam import User from modules.features.workflow import chatStart -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot +import modules.interfaces.interfaceDbChat as interfaceDbChat class WorkflowWithDocumentsTester: def __init__(self): # Use root user for testing (has full access to everything) - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() self.testUser = rootInterface.currentUser @@ -192,7 +192,7 @@ class WorkflowWithDocumentsTester: return False # Get current workflow status - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) currentWorkflow = interfaceDbChat.getWorkflow(self.workflow.id) if not currentWorkflow: @@ -225,7 +225,7 @@ class WorkflowWithDocumentsTester: if not self.workflow: return {"error": "No workflow to analyze"} - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) workflow = interfaceDbChat.getWorkflow(self.workflow.id) if not workflow: diff --git a/tests/functional/test06_workflow_prompt_variations.py b/tests/functional/test06_workflow_prompt_variations.py index 698c9698..106e2999 100644 --- a/tests/functional/test06_workflow_prompt_variations.py +++ b/tests/functional/test06_workflow_prompt_variations.py @@ -25,13 +25,13 @@ from modules.services import getInterface as getServices from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelUam import User from modules.features.workflow import chatStart -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot +import modules.interfaces.interfaceDbChat as interfaceDbChat class WorkflowPromptVariationsTester: def __init__(self): # Use root user for testing (has full access to everything) - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() self.testUser = rootInterface.currentUser @@ -115,7 +115,7 @@ class WorkflowPromptVariationsTester: return False # Get current workflow status - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) currentWorkflow = interfaceDbChat.getWorkflow(workflow.id) if not currentWorkflow: @@ -140,7 +140,7 @@ class WorkflowPromptVariationsTester: def _analyzeWorkflowResults(self, workflow: Any) -> Dict[str, Any]: """Analyze workflow results and extract information.""" - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) workflow = interfaceDbChat.getWorkflow(workflow.id) if not workflow: diff --git a/tests/functional/test09_document_generation_formats.py b/tests/functional/test09_document_generation_formats.py index d9c4d9b8..3c85460e 100644 --- a/tests/functional/test09_document_generation_formats.py +++ b/tests/functional/test09_document_generation_formats.py @@ -24,13 +24,13 @@ from modules.services import getInterface as getServices from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelUam import User from modules.features.workflow import chatStart -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot +import modules.interfaces.interfaceDbChat as interfaceDbChat class DocumentGenerationFormatsTester: def __init__(self): # Use root user for testing (has full access to everything) - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() self.testUser = rootInterface.currentUser @@ -251,7 +251,7 @@ class DocumentGenerationFormatsTester: startTime = time.time() lastStatus = None - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) if timeout is None: print("Waiting indefinitely (no timeout)") @@ -296,7 +296,7 @@ class DocumentGenerationFormatsTester: if not self.workflow: return {"error": "No workflow to analyze"} - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) workflow = interfaceDbChat.getWorkflow(self.workflow.id) if not workflow: diff --git a/tests/functional/test10_document_generation_formats.py b/tests/functional/test10_document_generation_formats.py index 45a364ce..19bdb12f 100644 --- a/tests/functional/test10_document_generation_formats.py +++ b/tests/functional/test10_document_generation_formats.py @@ -24,13 +24,13 @@ from modules.services import getInterface as getServices from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelUam import User from modules.features.workflow import chatStart -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot +import modules.interfaces.interfaceDbChat as interfaceDbChat class DocumentGenerationFormatsTester10: def __init__(self): # Use root user for testing (has full access to everything) - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() self.testUser = rootInterface.currentUser @@ -248,7 +248,7 @@ class DocumentGenerationFormatsTester10: startTime = time.time() lastStatus = None - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) if timeout is None: print("Waiting indefinitely (no timeout)") @@ -293,7 +293,7 @@ class DocumentGenerationFormatsTester10: if not self.workflow: return {"error": "No workflow to analyze"} - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) workflow = interfaceDbChat.getWorkflow(self.workflow.id) if not workflow: diff --git a/tests/functional/test11_code_generation_formats.py b/tests/functional/test11_code_generation_formats.py index 6d1735ad..64c8f93e 100644 --- a/tests/functional/test11_code_generation_formats.py +++ b/tests/functional/test11_code_generation_formats.py @@ -26,13 +26,13 @@ from modules.services import getInterface as getServices from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelUam import User from modules.features.workflow import chatStart -import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot +import modules.interfaces.interfaceDbChat as interfaceDbChat class CodeGenerationFormatsTester11: def __init__(self): # Use root user for testing (has full access to everything) - from modules.interfaces.interfaceDbAppObjects import getRootInterface + from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() self.testUser = rootInterface.currentUser @@ -190,7 +190,7 @@ class CodeGenerationFormatsTester11: startTime = time.time() lastStatus = None - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) if timeout is None: print("Waiting indefinitely (no timeout)") @@ -235,7 +235,7 @@ class CodeGenerationFormatsTester11: if not self.workflow: return {"error": "No workflow to analyze"} - interfaceDbChat = interfaceDbChatbot.getInterface(self.testUser) + interfaceDbChat = interfaceDbChat.getInterface(self.testUser) workflow = interfaceDbChat.getWorkflow(self.workflow.id) if not workflow: diff --git a/tests/integration/options/test_options_api.py b/tests/integration/options/test_options_api.py index 68a276e2..7007bf2a 100644 --- a/tests/integration/options/test_options_api.py +++ b/tests/integration/options/test_options_api.py @@ -9,7 +9,7 @@ import pytest import secrets from fastapi.testclient import TestClient from modules.datamodels.datamodelUam import User -from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceDbApp import getRootInterface @pytest.fixture