440 lines
18 KiB
Python
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}
|