gateway/modules/features/chatbotV2/interfaceFeatureChatbotV2.py

440 lines
18 KiB
Python

# 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}