# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Chatbot V2 routes - context-aware chat with file upload and extraction. """ import asyncio import json import math import logging import uuid from typing import Optional, Any, Dict, Union from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request, status, UploadFile, File from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.datamodels.datamodelChat import UserInputRequest from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata from modules.shared.timeUtils import getUtcTimestamp from . import interfaceFeatureChatbotV2 as interfaceDbChat from .interfaceFeatureChatbotV2 import getInterface as getChatbotV2Interface from .datamodelFeatureChatbotV2 import ChatbotV2Conversation from .serviceChatbotV2 import uploadAndExtract, chatProcessV2 from modules.features.chatbot.streaming.events import get_event_manager logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/chatbotv2", tags=["Chatbot V2"], responses={404: {"description": "Not found"}} ) class UploadRequest(BaseModel): """Request body for file upload - files must be uploaded to central storage first.""" listFileId: list[str] = Field(default_factory=list, description="List of file IDs from central storage") def _getServiceChat(context: RequestContext, instanceId: Optional[str] = None): """Get ChatbotV2 interface with instance context.""" mandateId = str(context.mandateId) if context.mandateId else None return getChatbotV2Interface( context.user, mandateId=mandateId, featureInstanceId=instanceId ) def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: """Validate that the user has access to the feature instance.""" rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=404, detail=f"Feature instance '{instanceId}' not found" ) if instance.featureCode != "chatbotv2": raise HTTPException( status_code=400, detail=f"Instance '{instanceId}' is not a chatbotv2 instance" ) if not context.hasSysAdminRole: featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) hasAccess = any( str(fa.featureInstanceId) == instanceId and fa.enabled for fa in featureAccesses ) if not hasAccess: raise HTTPException( status_code=403, detail=f"Access denied to feature instance '{instanceId}'" ) return str(instance.mandateId) # ============================================================================= # Upload - start extraction # ============================================================================= @router.post("/{instanceId}/upload") @limiter.limit("60/minute") async def upload_files( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), body: UploadRequest = Body(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Upload files as context and start extraction. Files must be uploaded to central storage first; pass their file IDs in listFileId. Returns conversationId. Extraction runs in background; poll threads or use SSE for status. """ mandateId = _validateInstanceAccess(instanceId, context) if not body.listFileId: raise HTTPException(status_code=400, detail="listFileId is required and must not be empty") try: conversation = await uploadAndExtract( context.user, mandateId=mandateId, instanceId=instanceId, listFileId=body.listFileId ) return { "conversationId": conversation.id, "status": conversation.status, "message": "Extraction started. Poll GET /threads?workflowId={} for status.".format(conversation.id) } except Exception as e: logger.error(f"Error in upload_files: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # ============================================================================= # List threads - MUST be first to avoid /{instanceId}/{workflowId} matching # ============================================================================= @router.get("/{instanceId}/threads") @limiter.limit("120/minute") def get_threads( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), workflowId: Optional[str] = Query(None, description="Optional workflow/conversation ID for details"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), context: RequestContext = Depends(getRequestContext) ) -> Union[PaginatedResponse, Dict[str, Any]]: """List conversations or get details for a specific one.""" _validateInstanceAccess(instanceId, context) interfaceDbChat = _getServiceChat(context, instanceId) if workflowId: conv = interfaceDbChat.getConversation(workflowId) if not conv: raise HTTPException(status_code=404, detail=f"Conversation {workflowId} not found") workflow_dict = conv.model_dump() chatData = interfaceDbChat.getUnifiedChatData(workflowId, None) return {"workflow": workflow_dict, "chatData": chatData} paginationParams = None if pagination: try: paginationDict = json.loads(pagination) paginationParams = PaginationParams(**paginationDict) if paginationDict else None except (json.JSONDecodeError, ValueError) as e: raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") all_convs = interfaceDbChat.getConversations(pagination=None) # all_convs from getConversations can be list of dicts (from getRecordsetWithRBAC) items = [c if isinstance(c, dict) else c.model_dump() for c in all_convs] if paginationParams: totalItems = len(items) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize workflows = items[startIdx:endIdx] else: workflows = items totalItems = len(items) totalPages = 1 metadata = PaginationMetadata( currentPage=paginationParams.page if paginationParams else 1, pageSize=paginationParams.pageSize if paginationParams else len(workflows), totalItems=totalItems, totalPages=totalPages, sort=paginationParams.sort if paginationParams else [], filters=paginationParams.filters if paginationParams else None ) return PaginatedResponse(items=workflows, pagination=metadata) # ============================================================================= # Start/continue chat (SSE stream) # ============================================================================= @router.post("/{instanceId}/start/stream") @limiter.limit("120/minute") async def stream_chat_start( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), workflowId: Optional[str] = Query(None, description="Optional conversation ID to continue"), userInput: UserInputRequest = Body(...), context: RequestContext = Depends(getRequestContext) ) -> StreamingResponse: """Start or continue a chat with SSE streaming.""" mandateId = _validateInstanceAccess(instanceId, context) event_manager = get_event_manager() final_workflow_id = workflowId or userInput.workflowId try: workflow = await chatProcessV2( context.user, mandateId=mandateId, userInput=userInput, conversationId=final_workflow_id, instanceId=instanceId ) if not workflow: raise HTTPException(status_code=500, detail="Failed to create or load workflow") queue = event_manager.get_queue(workflow.id) if not queue: queue = event_manager.create_queue(workflow.id) async def event_stream(): try: interfaceDbChat = _getServiceChat(context, instanceId) chatData = interfaceDbChat.getUnifiedChatData(workflow.id, None) if chatData.get("items"): for item in chatData["items"]: ser = { "type": item.get("type"), "createdAt": item.get("createdAt"), "item": item.get("item").model_dump() if hasattr(item.get("item"), "model_dump") else item.get("item") } yield f"data: {json.dumps(ser)}\n\n" keepalive_interval = 30.0 last_keepalive = asyncio.get_event_loop().time() status_check_interval = 5.0 last_status_check = asyncio.get_event_loop().time() timeout = 300.0 start_time = asyncio.get_event_loop().time() while True: elapsed = asyncio.get_event_loop().time() - start_time if elapsed > timeout: break if await request.is_disconnected(): break current_time = asyncio.get_event_loop().time() if current_time - last_status_check >= status_check_interval: try: cw = interfaceDbChat.getConversation(workflow.id) if cw and cw.status == "stopped": break except Exception: pass last_status_check = current_time try: event = await asyncio.wait_for(queue.get(), timeout=1.0) event_type = event.get("type") event_data = event.get("data", {}) if event_type == "chatdata" and event_data: if event_data.get("type") == "status": yield f"data: {json.dumps({'type': 'status', 'label': event_data.get('label', '')})}\n\n" else: item = event_data if isinstance(item, dict) and "item" in item: obj = item.get("item") if hasattr(obj, "model_dump"): item = {**item, "item": obj.model_dump()} yield f"data: {json.dumps(item)}\n\n" elif event_type in ("complete", "stopped"): break elif event_type == "error" and event.get("step") == "error": break last_keepalive = current_time except asyncio.TimeoutError: if current_time - last_keepalive >= keepalive_interval: yield ": keepalive\n\n" last_keepalive = current_time except Exception as e: logger.error(f"Error in event stream: {e}") break except Exception as e: logger.error(f"Error in event stream generator: {e}", exc_info=True) return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" } ) except HTTPException: raise except Exception as e: logger.error(f"Error in stream_chat_start: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # ============================================================================= # Stop chat # ============================================================================= @router.post("/{instanceId}/stop/{workflowId}") @limiter.limit("120/minute") async def stop_chat( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), workflowId: str = Path(..., description="Conversation ID to stop"), context: RequestContext = Depends(getRequestContext) ) -> ChatbotV2Conversation: """Stop a running chat.""" _validateInstanceAccess(instanceId, context) interfaceDbChat = _getServiceChat(context, instanceId) conv = interfaceDbChat.getConversation(workflowId) if not conv: raise HTTPException(status_code=404, detail=f"Conversation {workflowId} not found") interfaceDbChat.updateConversation(workflowId, {"status": "stopped", "lastActivity": getUtcTimestamp()}) interfaceDbChat.createLog({ "conversationId": workflowId, "message": "Workflow stopped by user", "type": "warning", "status": "stopped", "timestamp": getUtcTimestamp() }) event_manager = get_event_manager() await event_manager.emit_event( context_id=workflowId, event_type="stopped", data={"workflowId": workflowId}, event_category="workflow", message="Workflow stopped by user", step="stopped" ) return interfaceDbChat.getConversation(workflowId) # ============================================================================= # Delete conversation - use /conversations/{workflowId} to avoid # /{instanceId}/{workflowId} matching GET /threads (workflowId="threads") # ============================================================================= @router.delete("/{instanceId}/conversations/{workflowId}") @limiter.limit("120/minute") def delete_conversation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), workflowId: str = Path(..., description="Conversation ID to delete"), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a conversation and its data.""" _validateInstanceAccess(instanceId, context) interfaceDbChat = _getServiceChat(context, instanceId) conv = interfaceDbChat.getConversation(workflowId) if not conv: raise HTTPException(status_code=404, detail=f"Conversation {workflowId} not found") success = interfaceDbChat.deleteConversation(workflowId) if not success: raise HTTPException(status_code=500, detail="Failed to delete conversation") return {"id": workflowId, "message": "Conversation deleted successfully"}