""" Workflow routes for the backend API. Implements the endpoints for workflow management according to the state machine. """ import os import json import logging from typing import List, Dict, Any, Optional from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Response, status from dataclasses import dataclass from datetime import datetime # Import interfaces from modules.interfaces.lucydomInterface import getLucydomInterface from modules.security.auth import getCurrentActiveUser, getUserContext from modules.workflow.workflowManager import getWorkflowManager # Import models from modules.interfaces.lucydomModel import UserInputRequest # Configure logger logger = logging.getLogger(__name__) # Create router for workflow endpoints router = APIRouter( prefix="/api/workflows", tags=["Workflow"], responses={404: {"description": "Not found"}} ) class AppContext: def __init__(self, mandateId: int, userId: int): self._mandateId = mandateId self._userId = userId self.interfaceData = getLucydomInterface(mandateId, userId) async def getContext(currentUser: Dict[str, Any]) -> AppContext: mandateId, userId = await getUserContext(currentUser) return AppContext(mandateId, userId) # State 1: Workflow Initialization endpoint @router.post("/start", response_model=Dict[str, Any]) async def startWorkflow( workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue"), userInput: UserInputRequest = Body(...), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Starts a new workflow or continues an existing one. Corresponds to State 1 in the state machine documentation. Args: workflowId: Optional ID of an existing workflow to continue userInput: User input with prompt and optional file list currentUser: Authenticated user Returns: Dictionary with workflow ID and status """ context = await getContext(currentUser) try: # Convert the user input to a dictionary userInputDict = { "prompt": userInput.prompt, "listFileId": userInput.listFileId } # Start or continue workflow using the workflow manager workflow = await getWorkflowManager(context._mandateId, context._userId).workflowStart(userInputDict, workflowId) logger.info("User Input received. Answer:",workflow) return { "id": workflow.get("id"), "status": workflow.get("status", "running"), "message": "Workflow initialized and processing started" } except Exception as e: logger.error(f"Error starting workflow: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error starting workflow: {str(e)}" ) # State 8: Workflow Stopped endpoint @router.post("/{workflowId}/stop", response_model=Dict[str, Any]) async def stopWorkflow( workflowId: str = Path(..., description="ID of the workflow to stop"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Stops a running workflow. Corresponds to State 8 in the state machine documentation. Args: workflowId: ID of the workflow to stop currentUser: Authenticated user Returns: Dictionary with status information """ context = await getContext(currentUser) try: # Verify workflow exists and belongs to user workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow with ID {workflowId} not found" ) if workflow.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to stop this workflow" ) # Stop the workflow stoppedWorkflow = getWorkflowManager(context._mandateId, context._userId).workflowStop(workflowId) return { "id": workflowId, "status": stoppedWorkflow.get("status", "stopped"), "message": "Workflow has been stopped" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error stopping workflow: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error stopping workflow: {str(e)}" ) # State 11: Workflow Reset/Deletion endpoint @router.delete("/{workflowId}", response_model=Dict[str, Any]) async def deleteWorkflow( workflowId: str = Path(..., description="ID of the workflow to delete"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Deletes a workflow and its associated data. Corresponds to State 11 in the state machine documentation. Args: workflowId: ID of the workflow to delete currentUser: Authenticated user Returns: Dictionary with status information """ context = await getContext(currentUser) try: # Verify workflow exists workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow with ID {workflowId} not found" ) # Check if user has permission to delete if workflow.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to delete this workflow" ) # Delete workflow success = context.interfaceData.deleteWorkflow(workflowId) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete workflow" ) return { "id": workflowId, "message": "Workflow and associated data deleted successfully" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error deleting workflow: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error deleting workflow: {str(e)}" ) # API Endpoint for getting all workflows @router.get("", response_model=List[Dict[str, Any]]) async def listWorkflows( currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ List all workflows for the current user. Args: currentUser: Authenticated user Returns: List of workflow objects """ context = await getContext(currentUser) try: # Retrieve workflows for the user workflows = context.interfaceData.getWorkflowsByUser(context._userId) return workflows except Exception as e: logger.error(f"Error listing workflows: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error listing workflows: {str(e)}" ) # API Endpoint for workflow status @router.get("/{workflowId}/status", response_model=Dict[str, Any]) async def getWorkflowStatus( workflowId: str = Path(..., description="ID of the workflow"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Get the current status of a workflow. Args: workflowId: ID of the workflow currentUser: Authenticated user Returns: Dictionary with workflow status information """ context = await getContext(currentUser) try: # Retrieve workflow workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow with ID {workflowId} not found" ) # Create status response statusInfo = { "id": workflow.get("id"), "name": workflow.get("name"), "status": workflow.get("status"), "startedAt": workflow.get("startedAt"), "lastActivity": workflow.get("lastActivity"), "currentRound": workflow.get("currentRound", 1), "dataStats": workflow.get("dataStats", {}) } return statusInfo except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error getting workflow status: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error getting workflow status: {str(e)}" ) # API Endpoint for workflow logs with selective data transfer @router.get("/{workflowId}/logs", response_model=List[Dict[str, Any]]) async def getWorkflowLogs( workflowId: str = Path(..., description="ID of the workflow"), logId: Optional[str] = Query(None, description="Optional log ID to get only newer logs"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Get logs for a workflow with support for selective data transfer. If logId is provided, returns only logs with IDs equal to or newer than the specified ID. Args: workflowId: ID of the workflow logId: Optional ID to get only newer logs currentUser: Authenticated user Returns: List of log entries """ context = await getContext(currentUser) try: # Verify workflow exists workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow with ID {workflowId} not found" ) # Get all logs allLogs = context.interfaceData.getWorkflowLogs(workflowId) # Apply selective data transfer if logId is provided if logId: # Find the index of the specified log logIndex = next((i for i, log in enumerate(allLogs) if log.get("id") == logId), None) if logIndex is not None: # Return logs from this index onwards return allLogs[logIndex:] return allLogs except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error getting workflow logs: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error getting workflow logs: {str(e)}" ) # API Endpoint for workflow messages with selective data transfer @router.get("/{workflowId}/messages", response_model=List[Dict[str, Any]]) async def getWorkflowMessages( workflowId: str = Path(..., description="ID of the workflow"), messageId: Optional[str] = Query(None, description="Optional message ID to get only newer messages"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Get messages for a workflow with support for selective data transfer. If messageId is provided, returns only messages with IDs equal to or newer than the specified ID. Args: workflowId: ID of the workflow messageId: Optional ID to get only newer messages currentUser: Authenticated user Returns: List of message objects """ context = await getContext(currentUser) try: # Verify workflow exists workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow with ID {workflowId} not found" ) # Get all messages allMessages = context.interfaceData.getWorkflowMessages(workflowId) # Apply selective data transfer if messageId is provided if messageId: # Find the index of the specified message based on messageIds array messageIds = workflow.get("messageIds", []) if messageId in messageIds: messageIndex = messageIds.index(messageId) # Return messages from this index onwards based on the messageIds order filteredMessages = [] for msgId in messageIds[messageIndex:]: message = next((msg for msg in allMessages if msg.get("id") == msgId), None) if message: filteredMessages.append(message) return filteredMessages # Sort messages by sequenceNo allMessages.sort(key=lambda x: x.get("sequenceNo", 0)) return allMessages except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error getting workflow messages: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error getting workflow messages: {str(e)}" ) # Document Management Endpoints @router.delete("/{workflowId}/messages/{messageId}", response_model=Dict[str, Any]) async def deleteWorkflowMessage( workflowId: str = Path(..., description="ID of the workflow"), messageId: str = Path(..., description="ID of the message to delete"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Delete a message from a workflow. Args: workflowId: ID of the workflow messageId: ID of the message to delete currentUser: Authenticated user Returns: Dictionary with status information """ context = await getContext(currentUser) try: # Verify workflow exists and belongs to user workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow with ID {workflowId} not found" ) if workflow.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to modify this workflow" ) # Delete the message success = context.interfaceData.deleteWorkflowMessage(workflowId, messageId) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Message with ID {messageId} not found in workflow {workflowId}" ) # Update workflow's messageIds messageIds = workflow.get("messageIds", []) if messageId in messageIds: messageIds.remove(messageId) context.interfaceData.updateWorkflow(workflowId, {"messageIds": messageIds}) return { "workflowId": workflowId, "messageId": messageId, "message": "Message deleted successfully" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error deleting message: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error deleting message: {str(e)}" ) @router.delete("/{workflowId}/messages/{messageId}/files/{fileId}", response_model=Dict[str, Any]) async def deleteFileFromMessage( workflowId: str = Path(..., description="ID of the workflow"), messageId: str = Path(..., description="ID of the message"), fileId: str = Path(..., description="ID of the file to delete"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Delete a file reference from a message in a workflow. The file itself is not deleted from the database, only the reference in the message. Args: workflowId: ID of the workflow messageId: ID of the message fileId: ID of the file to delete currentUser: Authenticated user Returns: Dictionary with status information """ context = await getContext(currentUser) try: # Verify workflow exists and belongs to user workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow with ID {workflowId} not found" ) if workflow.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to modify this workflow" ) # Delete file reference from message success = context.interfaceData.deleteFileFromMessage(workflowId, messageId, fileId) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"File with ID {fileId} not found in message {messageId}" ) return { "workflowId": workflowId, "messageId": messageId, "fileId": fileId, "message": "File reference deleted successfully" } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error deleting file reference: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error deleting file reference: {str(e)}" ) # File preview and download routes @router.get("/files/{fileId}/preview", response_model=Dict[str, Any]) async def previewFile( fileId: str = Path(..., description="ID of the file to preview"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Get file metadata and a preview of the file content. Args: fileId: ID of the file currentUser: Authenticated user Returns: Dictionary with file metadata and preview content """ context = await getContext(currentUser) try: # Get file metadata file = context.interfaceData.getFile(fileId) if not file: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"File with ID {fileId} not found" ) # Check if file belongs to user or their mandate if file.get("_mandateId") != context._mandateId and file.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to access this file" ) # Get file data (limited for preview) fileData = context.interfaceData.getFileData(fileId) if fileData is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"File data not found for file ID {fileId}" ) # For text-based files, return a preview of the content mimeType = file.get("mimeType", "application/octet-stream") isText = mimeType.startswith("text/") or mimeType in [ "application/json", "application/xml", "application/javascript" ] previewData = None # Get base64Encoded flag from database fileDataEntries = context.interfaceData.db.getRecordset("fileData", recordFilter={"id": fileId}) if fileDataEntries and "base64Encoded" in fileDataEntries[0]: # Use the flag from the database base64Encoded = fileDataEntries[0]["base64Encoded"] else: # Determine based on file type (fallback for older data) base64Encoded = not isText if isText: # Convert to string without trim for preview if isinstance(fileData, bytes): try: filePreview = fileData.decode('utf-8') previewData = filePreview except UnicodeDecodeError: # Try other encodings for encoding in ['latin-1', 'cp1252', 'iso-8859-1']: try: filePreview = fileData.decode(encoding) previewData = filePreview break except UnicodeDecodeError: continue # For images, return base64 encoded data if mimeType.startswith("image/"): import base64 previewData = base64.b64encode(fileData).decode('utf-8') base64Encoded = True # Return file metadata with preview and base64Encoded flag return { "id": fileId, "name": file.get("name"), "mimeType": mimeType, "size": file.get("size"), "creationDate": file.get("creationDate"), "isPreviewable": isText or mimeType.startswith("image/"), "preview": previewData, "base64Encoded": base64Encoded } except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error previewing file: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error previewing file: {str(e)}" ) @router.get("/files/{fileId}/download") async def downloadFile( fileId: str = Path(..., description="ID of the file to download"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ Download a file. Args: fileId: ID of the file currentUser: Authenticated user Returns: File data with appropriate headers """ context = await getContext(currentUser) try: # Get file data fileInfo = context.interfaceData.downloadFile(fileId) if not fileInfo: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"File with ID {fileId} not found" ) # Return file as response return Response( content=fileInfo["content"], media_type=fileInfo["contentType"], headers={ "Content-Disposition": f"attachment; filename={fileInfo['name']}" } ) except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Error downloading file: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error downloading file: {str(e)}" ) @router.get("/workflows", response_model=List[Dict[str, Any]]) async def getWorkflows(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): context = await getContext(currentUser) # Get all workflows for the mandate workflows = context.interfaceData.getWorkflowsByMandate(context._mandateId) return workflows @router.post("/workflows", response_model=Dict[str, Any]) async def createWorkflow( workflow: Dict[str, Any], currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): context = await getContext(currentUser) # Create workflow newWorkflow = context.interfaceData.createWorkflow(workflow) return newWorkflow @router.get("/workflows/{workflowId}", response_model=Dict[str, Any]) async def getWorkflow( workflowId: str = Path(..., description="ID of the workflow"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): context = await getContext(currentUser) # Get workflow workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException(status_code=404, detail="Workflow not found") # Check if user has access to this workflow if workflow.get("_userId") != context._userId: raise HTTPException(status_code=403, detail="Not authorized to access this workflow") return workflow @router.put("/workflows/{workflowId}", response_model=Dict[str, Any]) async def updateWorkflow( workflow: Dict[str, Any], workflowId: str = Path(..., description="ID of the workflow to update"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): context = await getContext(currentUser) # Get workflow existingWorkflow = context.interfaceData.getWorkflow(workflowId) if not existingWorkflow: raise HTTPException(status_code=404, detail="Workflow not found") # Check if user has access to this workflow if existingWorkflow.get("_userId") != context._userId: raise HTTPException(status_code=403, detail="Not authorized to update this workflow") # Update workflow updatedWorkflow = context.interfaceData.updateWorkflow(workflowId, workflow) return updatedWorkflow @router.delete("/workflows/{workflowId}") async def deleteWorkflow( workflowId: str = Path(..., description="ID of the workflow to delete"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): context = await getContext(currentUser) # Get workflow workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException(status_code=404, detail="Workflow not found") # Check if user has access to this workflow if workflow.get("_userId") != context._userId: raise HTTPException(status_code=403, detail="Not authorized to delete this workflow") # Delete workflow success = context.interfaceData.deleteWorkflow(workflowId) if not success: raise HTTPException(status_code=500, detail="Failed to delete workflow") return {"status": "success"} @router.post("/workflows/{workflowId}/files/{fileId}") async def addFileToWorkflow( workflowId: str = Path(..., description="ID of the workflow"), fileId: str = Path(..., description="ID of the file to add"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Add a file to a workflow.""" context = await getContext(currentUser) # Get workflow workflow = context.interfaceData.getWorkflow(workflowId) if not workflow: raise HTTPException(status_code=404, detail="Workflow not found") # Check access if workflow.get("_userId") != context._userId: raise HTTPException(status_code=403, detail="No access to this workflow") # Get file file = context.interfaceData.getFile(fileId) if not file: raise HTTPException(status_code=404, detail="File not found") # Check file access if file.get("_mandateId") != context._mandateId and file.get("_userId") != context._userId: raise HTTPException(status_code=403, detail="No access to this file") # Add file to workflow success = context.interfaceData.addFileToWorkflow(workflowId, fileId) if not success: raise HTTPException(status_code=500, detail="Failed to add file to workflow") return {"status": "success"}