"""AI Participant Handler — bridges the kommunikation plugin with the AI Assistant. When a message is received in a conversation that includes the 'ai' participant, this handler generates an LLM response using litellm.acompletion (non-streaming) and returns it as a new message in the conversation. """ from __future__ import annotations import logging import uuid from typing import Any import litellm from app.core.db import create_db_session from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler logger = logging.getLogger(__name__) class AIParticipantHandler(ParticipantHandler): """Handles AI responses as a participant in kommunikation conversations.""" def __init__(self, container: Any) -> None: self._container = container async def on_message_received( self, conversation_id: Any, message: dict[str, Any], conversation: dict[str, Any], mentions: list[str], context: dict[str, Any], ) -> list[dict[str, Any]] | None: """Generate an AI response when the AI is mentioned or in a direct chat. Checks: 1. 'ai' is in the conversation participants as a participant_type 2. '@KI' is in mentions OR the conversation is_direct with only user+ai Returns a list with one message dict containing the AI response. """ # Check if 'ai' is a participant in this conversation participants = conversation.get("participants", []) ai_is_participant = any( p.get("participant_type") == "ai" for p in participants ) if not ai_is_participant: return None # Check if AI is mentioned or it's a direct chat with only user + ai ai_mentioned = "KI" in mentions or "ai" in mentions is_direct = conversation.get("is_direct", False) if is_direct: # For direct chats, check that only user and ai are participants non_system_participants = [ p for p in participants if p.get("participant_type") in ("user", "ai") ] if len(non_system_participants) <= 2: ai_mentioned = True if not ai_mentioned: return None # Don't respond to our own messages if message.get("sender_type") == "ai": return None # Get tenant_id and user_id from context tenant_id_str = context.get("tenant_id") or message.get("tenant_id") if not tenant_id_str: logger.warning("AIParticipantHandler: missing tenant_id in context") return None try: tenant_id = uuid.UUID(str(tenant_id_str)) except (ValueError, TypeError): logger.warning("AIParticipantHandler: invalid tenant_id: %s", tenant_id_str) return None # Build messages from conversation history and generate response try: from app.plugins.builtins.ai_assistant.services import ( build_litellm_params, get_default_agent, ) async with create_db_session(tenant_id) as db: # Get default agent for system prompt and preset configuration agent = await get_default_agent(db, tenant_id) # Build message history from conversation messages messages = await self._build_message_history( db, conversation_id, tenant_id, message ) if agent: params, model_id = await build_litellm_params( db, agent, messages, tenant_id ) else: # Fallback: use default provider without agent from app.plugins.builtins.ai_assistant.services import ( get_default_provider, ) provider = await get_default_provider(db, tenant_id) if not provider: return [ { "content": "Kein AI-Provider konfiguriert. Bitte konfigurieren Sie einen Provider in den KI-Einstellungen.", "content_format": "text", } ] model_id = "gpt-4o-mini" litellm_model = f"{provider.provider_type}/{model_id}" params: dict[str, Any] = { "model": litellm_model, "messages": messages, "temperature": 0.7, "max_tokens": 2048, "stream": False, } if provider.api_key: params["api_key"] = provider.api_key if provider.base_url: params["api_base"] = provider.base_url # Ensure non-streaming for acompletion params["stream"] = False response = await litellm.acompletion(**params) response_text = response.choices[0].message.content or "" if not response_text.strip(): response_text = "*(keine Antwort generiert)*" return [ { "content": response_text, "content_format": "markdown", } ] except Exception as exc: logger.exception("AIParticipantHandler: error generating AI response") return [ { "content": f"Fehler bei der KI-Antwort: {exc}", "content_format": "text", } ] async def _build_message_history( self, db: Any, conversation_id: Any, tenant_id: uuid.UUID, current_message: dict[str, Any], ) -> list[dict[str, str]]: """Build a messages array from the conversation history for the LLM.""" from app.plugins.builtins.kommunikation.services import get_messages messages: list[dict[str, str]] = [] # Get conversation history (last 50 messages) try: result = await get_messages( db, tenant_id, uuid.UUID(str(conversation_id)), page=1, page_size=50, ) items = result.get("items", []) for item in items: role = "assistant" if item.get("sender_type") == "ai" else "user" content = item.get("content", "") if content: messages.append({"role": role, "content": content}) except Exception: logger.debug("Could not load conversation history, using current message only") # Ensure the current message is included current_content = current_message.get("content", "") if current_content and ( not messages or messages[-1].get("content") != current_content ): messages.append({"role": "user", "content": current_content}) return messages async def handle_event(self, payload: dict[str, Any]) -> None: """Handle a message.received event from the event bus. Extracts conversation_id from the payload, loads the conversation, and calls on_message_received. If a response is generated, sends it back to the conversation. """ conversation_id_str = payload.get("conversation_id") tenant_id_str = payload.get("tenant_id") message_content = payload.get("content", "") message_id = payload.get("message_id") sender_type = payload.get("sender_type", "user") if not conversation_id_str or not tenant_id_str: logger.warning("AIParticipantHandler.handle_event: missing conversation_id or tenant_id") return try: tenant_id = uuid.UUID(str(tenant_id_str)) conversation_id = uuid.UUID(str(conversation_id_str)) except (ValueError, TypeError): logger.warning("AIParticipantHandler.handle_event: invalid UUID in payload") return # Build the message dict message: dict[str, Any] = { "id": message_id, "content": message_content, "sender_type": sender_type, "tenant_id": tenant_id_str, } # Load conversation try: from app.plugins.builtins.kommunikation.services import get_conversation async with create_db_session(tenant_id) as db: # We need a user_id to load the conversation — use the sender_id from payload user_id_str = payload.get("sender_id") if not user_id_str: logger.warning("AIParticipantHandler.handle_event: missing sender_id") return user_id = uuid.UUID(str(user_id_str)) conversation = await get_conversation(db, tenant_id, conversation_id, user_id) if not conversation: logger.warning("AIParticipantHandler.handle_event: conversation not found") return # Parse mentions from message content from app.plugins.builtins.kommunikation.services import parse_mentions mentions = parse_mentions(message_content) context: dict[str, Any] = { "tenant_id": tenant_id_str, "user_id": user_id_str, } # Call on_message_received response_messages = await self.on_message_received( conversation_id, message, conversation, mentions, context ) # If we got a response, send it to the conversation if response_messages: from app.plugins.builtins.kommunikation.services import send_message for resp_msg in response_messages: await send_message( db=db, tenant_id=tenant_id, conversation_id=conversation_id, sender_id=None, sender_type="ai", content=resp_msg.get("content", ""), content_format=resp_msg.get("content_format", "text"), blocks=resp_msg.get("blocks"), ) await db.commit() except Exception: logger.exception("AIParticipantHandler.handle_event: error processing event") def get_participant_info(self) -> dict[str, Any]: """Return metadata about this participant.""" return { "display_name": "KI Assistent", "capabilities": ["chat", "tools", "streaming"], "description": "KI Assistent für Chat und Tool-Nutzung", }