# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Interface to Chatbot V2 database. Manages context-aware conversations with file upload and extraction. """ import logging import math import uuid from typing import Dict, Any, List, Optional, Union from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.security.rbac import RbacClass from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelUam import User, AccessLevel from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from .datamodelFeatureChatbotV2 import ( ChatbotV2Conversation, ChatbotV2ContextFile, ChatbotV2ExtractedContext, ChatbotV2Message, ChatbotV2Document, ChatbotV2Log, ) logger = logging.getLogger(__name__) _chatbotV2Interfaces: Dict[str, "ChatbotV2Objects"] = {} FEATURE_CODE = "chatbotv2" def getInterface( currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None ) -> "ChatbotV2Objects": """Get or create a ChatbotV2Objects instance for the given user context.""" if not currentUser or not currentUser.id: raise ValueError("Valid user context required") key = f"{currentUser.id}_{mandateId or ''}_{featureInstanceId or ''}" if key not in _chatbotV2Interfaces: _chatbotV2Interfaces[key] = ChatbotV2Objects( currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId ) else: _chatbotV2Interfaces[key].setUserContext( currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId ) return _chatbotV2Interfaces[key] class ChatbotV2Objects: """Interface to Chatbot V2 database.""" def __init__( self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None ): self.currentUser = currentUser self.userId = currentUser.id if currentUser else None self.mandateId = mandateId self.featureInstanceId = featureInstanceId self.featureCode = FEATURE_CODE self.rbac = None self._initializeDatabase() if currentUser: self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) def setUserContext( self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None ): """Set user context for the interface.""" self.currentUser = currentUser self.userId = currentUser.id self.mandateId = mandateId self.featureInstanceId = featureInstanceId if not self.userId: raise ValueError("Invalid user context: id is required") from modules.security.rootAccess import getRootDbAppConnector dbApp = getRootDbAppConnector() self.rbac = RbacClass(self.db, dbApp=dbApp) self.db.updateContext(self.userId) def __del__(self): 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): try: dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") dbDatabase = "poweron_chatbotv2" dbUser = APP_CONFIG.get("DB_USER") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) self.db = DatabaseConnector( dbHost=dbHost, dbDatabase=dbDatabase, dbUser=dbUser, dbPassword=dbPassword, dbPort=dbPort, userId=self.userId ) logger.info("ChatbotV2 database initialized successfully") except Exception as e: logger.error(f"Failed to initialize ChatbotV2 database: {e}") raise def checkRbacPermission( self, modelClass: type, operation: str, recordId: Optional[str] = None ) -> bool: """Check RBAC permission for an operation on a table.""" if not self.rbac or not self.currentUser: return False from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(modelClass.__name__, featureCode=self.featureCode) permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, objectKey, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId ) if operation == "create": return permissions.create != AccessLevel.NONE if operation == "update": return permissions.update != AccessLevel.NONE if operation == "delete": return permissions.delete != AccessLevel.NONE if operation == "read": return permissions.read != AccessLevel.NONE return False def _separateObjectFields(self, model_class, data: Dict[str, Any]) -> tuple: """Separate simple fields from object fields (contextFiles, messages).""" simple_fields = {} object_fields = {} model_fields = model_class.model_fields for field_name, value in data.items(): if field_name in model_fields: if field_name in ("contextFiles", "messages"): object_fields[field_name] = value continue if field_name.startswith("_"): simple_fields[field_name] = value elif isinstance(value, (str, int, float, bool, type(None))): simple_fields[field_name] = value elif field_name in model_fields: field_info = model_fields[field_name] if hasattr(field_info, "annotation"): from typing import get_origin, get_args origin = get_origin(field_info.annotation) if origin in (dict, list): simple_fields[field_name] = value else: object_fields[field_name] = value else: object_fields[field_name] = value return simple_fields, object_fields # ===== Conversation CRUD ===== def getConversations( self, pagination: Optional[PaginationParams] = None ) -> Union[List[Dict[str, Any]], PaginatedResult]: """Get conversations for current feature instance.""" record_filter = {} if self.featureInstanceId: record_filter["featureInstanceId"] = self.featureInstanceId records = getRecordsetWithRBAC( self.db, ChatbotV2Conversation, self.currentUser, recordFilter=record_filter if record_filter else None, orderBy="lastActivity", mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.featureCode ) if pagination is None: return records total = len(records) page = pagination.page or 1 page_size = pagination.pageSize or 20 start = (page - 1) * page_size items = records[start : start + page_size] total_pages = math.ceil(total / page_size) if total > 0 else 0 return PaginatedResult(items=items, totalItems=total, totalPages=total_pages) def getConversation(self, conversationId: str) -> Optional[ChatbotV2Conversation]: """Get a conversation by ID with hydrated context files and messages.""" records = getRecordsetWithRBAC( self.db, ChatbotV2Conversation, self.currentUser, recordFilter={"id": conversationId}, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.featureCode ) if not records: return None r = records[0] context_files = self.getContextFiles(conversationId) messages = self.getMessages(conversationId) max_steps = r.get("maxSteps") if max_steps is None: max_steps = 10 return ChatbotV2Conversation( id=r["id"], featureInstanceId=r.get("featureInstanceId", "") or self.featureInstanceId or "", mandateId=r.get("mandateId"), name=r.get("name"), status=r.get("status", "extracting"), currentRound=r.get("currentRound", 0), lastActivity=r.get("lastActivity", getUtcTimestamp()), startedAt=r.get("startedAt", getUtcTimestamp()), extractedContextId=r.get("extractedContextId"), maxSteps=max_steps, contextFiles=context_files, messages=messages ) def createConversation(self, data: Dict[str, Any]) -> ChatbotV2Conversation: """Create a new conversation.""" if not self.checkRbacPermission(ChatbotV2Conversation, "create"): raise PermissionError("No permission to create conversations") data["featureInstanceId"] = data.get("featureInstanceId") or self.featureInstanceId or "" data["mandateId"] = data.get("mandateId") or self.mandateId simple, obj = self._separateObjectFields(ChatbotV2Conversation, data) if "maxSteps" not in simple or simple["maxSteps"] is None: simple["maxSteps"] = 10 created = self.db.recordCreate(ChatbotV2Conversation, simple) max_steps = created.get("maxSteps") if max_steps is None: max_steps = 10 return ChatbotV2Conversation( id=created["id"], featureInstanceId=created.get("featureInstanceId", ""), mandateId=created.get("mandateId"), name=created.get("name"), status=created.get("status", "extracting"), currentRound=created.get("currentRound", 0), lastActivity=created.get("lastActivity", getUtcTimestamp()), startedAt=created.get("startedAt", getUtcTimestamp()), extractedContextId=created.get("extractedContextId"), maxSteps=max_steps, contextFiles=[], messages=[] ) def updateConversation(self, conversationId: str, data: Dict[str, Any]) -> Optional[ChatbotV2Conversation]: """Update a conversation.""" conv = self.getConversation(conversationId) if not conv: return None if not self.checkRbacPermission(ChatbotV2Conversation, "update"): raise PermissionError("No permission to update conversation") simple, _ = self._separateObjectFields(ChatbotV2Conversation, data) simple["lastActivity"] = getUtcTimestamp() updated = self.db.recordModify(ChatbotV2Conversation, conversationId, simple) if not updated: return None return self.getConversation(conversationId) def deleteConversation(self, conversationId: str) -> bool: """Delete a conversation and all related data.""" conv = self.getConversation(conversationId) if not conv: return False if not self.checkRbacPermission(ChatbotV2Conversation, "delete"): raise PermissionError("No permission to delete conversation") for cf in self.getContextFiles(conversationId): self.db.recordDelete(ChatbotV2ContextFile, cf.id) ctx = self.getExtractedContextByConversation(conversationId) if ctx: self.db.recordDelete(ChatbotV2ExtractedContext, ctx.id) for msg in self.getMessages(conversationId): for doc in self.getDocuments(msg.id): self.db.recordDelete(ChatbotV2Document, doc.id) self.db.recordDelete(ChatbotV2Message, msg.id) for log in self.getLogs(conversationId): self.db.recordDelete(ChatbotV2Log, log.id) return self.db.recordDelete(ChatbotV2Conversation, conversationId) # ===== Context File CRUD ===== def getContextFiles(self, conversationId: str) -> List[ChatbotV2ContextFile]: """Get context files for a conversation.""" records = getRecordsetWithRBAC( self.db, ChatbotV2ContextFile, self.currentUser, recordFilter={"conversationId": conversationId}, orderBy="uploadOrder", mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.featureCode ) return [ChatbotV2ContextFile(**r) for r in records] def createContextFile(self, data: Dict[str, Any]) -> ChatbotV2ContextFile: """Create a context file record.""" return ChatbotV2ContextFile(**self.db.recordCreate(ChatbotV2ContextFile, data)) # ===== Extracted Context CRUD ===== def getExtractedContext(self, extractedContextId: str) -> Optional[ChatbotV2ExtractedContext]: """Get extracted context by ID.""" records = self.db.getRecordset( ChatbotV2ExtractedContext, recordFilter={"id": extractedContextId} ) if not records: return None return ChatbotV2ExtractedContext(**records[0]) def getExtractedContextByConversation(self, conversationId: str) -> Optional[ChatbotV2ExtractedContext]: """Get extracted context for a conversation.""" records = self.db.getRecordset( ChatbotV2ExtractedContext, recordFilter={"conversationId": conversationId} ) if not records: return None return ChatbotV2ExtractedContext(**records[0]) def createExtractedContext(self, data: Dict[str, Any]) -> ChatbotV2ExtractedContext: """Create extracted context record.""" created = self.db.recordCreate(ChatbotV2ExtractedContext, data) return ChatbotV2ExtractedContext(**created) def updateExtractedContext(self, extractedContextId: str, data: Dict[str, Any]) -> Optional[ChatbotV2ExtractedContext]: """Update extracted context.""" updated = self.db.recordModify(ChatbotV2ExtractedContext, extractedContextId, data) return ChatbotV2ExtractedContext(**updated) if updated else None # ===== Message CRUD ===== def getMessages(self, conversationId: str) -> List[ChatbotV2Message]: """Get messages for a conversation.""" records = getRecordsetWithRBAC( self.db, ChatbotV2Message, self.currentUser, recordFilter={"conversationId": conversationId}, orderBy="sequenceNr", mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.featureCode ) return [ChatbotV2Message(**r) for r in records] def createMessage(self, data: Dict[str, Any]) -> ChatbotV2Message: """Create a message.""" if "id" not in data or not data["id"]: data["id"] = str(uuid.uuid4()) created = self.db.recordCreate(ChatbotV2Message, data) return ChatbotV2Message(**created) # ===== Document CRUD ===== def getDocuments(self, messageId: str) -> List[ChatbotV2Document]: """Get documents for a message.""" records = self.db.getRecordset( ChatbotV2Document, recordFilter={"messageId": messageId} ) return [ChatbotV2Document(**r) for r in records] def createDocument(self, data: Dict[str, Any]) -> ChatbotV2Document: """Create a document record.""" created = self.db.recordCreate(ChatbotV2Document, data) return ChatbotV2Document(**created) # ===== Log CRUD ===== def getLogs(self, conversationId: str) -> List[ChatbotV2Log]: """Get logs for a conversation.""" records = self.db.getRecordset( ChatbotV2Log, recordFilter={"conversationId": conversationId} ) # Sort by timestamp (connector doesn't support orderBy) logs = [ChatbotV2Log(**r) for r in records] logs.sort(key=lambda log: parseTimestamp(log.timestamp, default=0)) return logs def createLog(self, data: Dict[str, Any]) -> ChatbotV2Log: """Create a log entry.""" if "timestamp" not in data: data["timestamp"] = getUtcTimestamp() created = self.db.recordCreate(ChatbotV2Log, data) return ChatbotV2Log(**created) # ===== Unified Chat Data (for streaming/polling) ===== def getUnifiedChatData( self, conversationId: str, afterTimestamp: Optional[float] = None ) -> Dict[str, Any]: """Get unified chat data (messages, logs) in chronological order.""" conv = self.getConversation(conversationId) if not conv: return {"items": []} items = [] for msg in conv.messages: ts = parseTimestamp(msg.publishedAt, default=getUtcTimestamp()) if afterTimestamp is not None and ts <= afterTimestamp: continue items.append({"type": "message", "createdAt": ts, "item": msg}) for log in self.getLogs(conversationId): ts = parseTimestamp(log.timestamp, default=getUtcTimestamp()) if afterTimestamp is not None and ts <= afterTimestamp: continue items.append({"type": "log", "createdAt": ts, "item": log}) items.sort(key=lambda x: x.get("createdAt", 0)) return {"items": items}