import logging from typing import Dict, Any, List, Optional, Tuple, Union from modules.datamodels.datamodelChat import ChatDocument from modules.services.serviceExtraction.mainServiceExtraction import ExtractionService from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, ModelCapabilities, OperationType, Priority from modules.datamodels.datamodelWeb import ( WebSearchRequest, WebCrawlRequest, WebScrapeRequest, WebSearchActionResult, WebCrawlActionResult, WebScrapeActionResult, ) from modules.interfaces.interfaceAiObjects import AiObjects logger = logging.getLogger(__name__) # Model registry is now provided by interfaces via AiModels class AiService: """Centralized AI service orchestrating documents, model selection, failover, and web operations. """ def __init__(self, serviceCenter=None) -> None: """Initialize AI service with service center access. Args: serviceCenter: Service center instance for accessing other services """ self.serviceCenter = serviceCenter # Only depend on interfaces self.aiObjects = None # Will be initialized in create() self.extractionService = ExtractionService() @classmethod async def create(cls, serviceCenter=None) -> "AiService": """Create AiService instance with all connectors initialized.""" instance = cls(serviceCenter) instance.aiObjects = await AiObjects.create() return instance # AI Text Generation async def callAiText( self, prompt: str, documents: Optional[List[ChatDocument]] = None, processDocumentsIndividually: bool = False, options: Optional[AiCallOptions] = None, ) -> str: """Call AI for text generation using interface.call().""" try: documentContent = "" if documents: documentContent = await self._processDocumentsForAi( documents, options.operationType if options else "general", options.compressContext if options else True, options.processDocumentsIndividually if options else processDocumentsIndividually, ) effectiveOptions = options or AiCallOptions() # Compute maxContextBytes if not provided: conservative defaults per model tag could be added here if options and options.maxContextBytes is None: options.maxContextBytes = 16000 # bytes, conservative default if model limit unknown request = AiCallRequest( prompt=prompt, context=documentContent or None, options=effectiveOptions, ) response = await self.aiObjects.call(request) return response.content except Exception as e: logger.error(f"Error in AI text generation: {str(e)}") return f"Error: {str(e)}" # AI Image Analysis async def callAiImage( self, prompt: str, imageData: Union[str, bytes], mimeType: str = None, options: Optional[AiCallOptions] = None, ) -> str: """Call AI for image analysis using interface.callImage().""" try: return await self.aiObjects.callImage(prompt, imageData, mimeType, options) except Exception as e: logger.error(f"Error in AI image analysis: {str(e)}") return f"Error: {str(e)}" # AI Image Generation async def generateImage( self, prompt: str, size: str = "1024x1024", quality: str = "standard", style: str = "vivid", options: Optional[AiCallOptions] = None, ) -> Dict[str, Any]: """Generate an image using AI using interface.generateImage().""" try: return await self.aiObjects.generateImage(prompt, size, quality, style, options) except Exception as e: logger.error(f"Error in AI image generation: {str(e)}") return {"success": False, "error": str(e)} # Web Research (using LangDoc AI) async def webResearch( self, query: str, context: str = "", options: Optional[AiCallOptions] = None, ) -> str: """Perform web research using LangDoc AI via interface.webQuery().""" try: return await self.aiObjects.webQuery(query, context, options) except Exception as e: logger.error(f"Error in web research: {str(e)}") return f"Error: {str(e)}" # Web Search (using Tavily) async def webSearch( self, request: WebSearchRequest, ) -> WebSearchActionResult: """Perform web search using Tavily via interface.webSearch().""" try: return await self.aiObjects.webSearch(request) except Exception as e: logger.error(f"Error in web search: {str(e)}") return WebSearchActionResult(success=False, error=str(e)) # Web Crawl (using Tavily) async def webCrawl( self, request: WebCrawlRequest, ) -> WebCrawlActionResult: """Crawl web pages using Tavily via interface.webCrawl().""" try: return await self.aiObjects.webCrawl(request) except Exception as e: logger.error(f"Error in web crawl: {str(e)}") return WebCrawlActionResult(success=False, error=str(e)) # Web Scrape (using Tavily) async def webScrape( self, request: WebScrapeRequest, ) -> WebScrapeActionResult: """Scrape web content using Tavily via interface.webScrape().""" try: return await self.aiObjects.webScrape(request) except Exception as e: logger.error(f"Error in web scrape: {str(e)}") return WebScrapeActionResult(success=False, error=str(e)) async def _processDocumentsForAi( self, documents: List[ChatDocument], operationType: str, compressDocuments: bool, processIndividually: bool, ) -> str: if not documents: return "" # Build extraction options extractionOptions: Dict[str, Any] = { "prompt": f"Extract relevant content for {operationType}", "operationType": operationType, "processDocumentsIndividually": processIndividually, # Respect size/ chunking hints if provided via AiCallOptions "maxSize": getattr(getattr(self, "_aiOptions", None), "maxContextBytes", None) or 0, "chunkAllowed": getattr(getattr(self, "_aiOptions", None), "chunkAllowed", True), # basic merge strategy for text by parent "mergeStrategy": {"groupBy": "parentId", "orderBy": "pageIndex"}, } # Prepare documentList for extractor documentList: List[Dict[str, Any]] = [] for d in documents: documentList.append({ "id": d.id, "bytes": d.fileData, "fileName": d.fileName, "mimeType": d.mimeType, }) processedContents: List[str] = [] try: extractionResult = self.extractionService.extractContent(documentList, extractionOptions) def _partsToText(parts) -> str: lines: List[str] = [] for p in parts: if p.typeGroup in ("text", "table", "structure") and p.data and isinstance(p.data, str): lines.append(p.data) return "\n\n".join(lines) if isinstance(extractionResult, list): for i, ec in enumerate(extractionResult): try: contentText = _partsToText(ec.parts) if compressDocuments and len(contentText.encode("utf-8")) > 10000: contentText = await self._compressContent(contentText, 10000, "document") processedContents.append(contentText) except Exception as e: logger.warning(f"Error aggregating extracted content: {str(e)}") processedContents.append("[Error aggregating content]") else: # Fallback: no content contentText = "" if compressDocuments and len(contentText.encode("utf-8")) > 10000: contentText = await self._compressContent(contentText, 10000, "document") processedContents.append(contentText) except Exception as e: logger.warning(f"Error during extraction: {str(e)}") processedContents.append("[Error during extraction]") return "\n\n---\n\n".join(processedContents) async def _compressContent(self, content: str, targetSize: int, contentType: str) -> str: if len(content.encode("utf-8")) <= targetSize: return content try: compressionPrompt = f""" Komprimiere den folgenden {contentType} auf maximal {targetSize} Zeichen, behalte aber alle wichtigen Informationen bei: {content} Gib nur den komprimierten Inhalt zurück, ohne zusätzliche Erklärungen. """ # Service must not call connectors directly; use simple truncation fallback here data = content.encode("utf-8") return data[:targetSize].decode("utf-8", errors="ignore") + "... [truncated]" except Exception as e: logger.warning(f"AI compression failed, using truncation: {str(e)}") return content[:targetSize] + "... [truncated]" # ===== DYNAMIC GENERIC AI CALLS IMPLEMENTATION ===== async def callAi( self, prompt: str, documents: Optional[List[ChatDocument]] = None, placeholders: Optional[Dict[str, str]] = None, options: Optional[AiCallOptions] = None ) -> str: """ Unified AI call interface that automatically routes to appropriate handler. Args: prompt: The main prompt for the AI call documents: Optional list of documents to process placeholders: Optional dictionary of placeholder replacements for planning calls options: AI call configuration options Returns: AI response as string Raises: Exception: If all available models fail """ if options is None: options = AiCallOptions() # Auto-determine call type based on documents and operation type call_type = self._determineCallType(documents, options.operationType) options.callType = call_type if call_type == "planning": return await self._callAiPlanning(prompt, placeholders, options) else: return await self._callAiText(prompt, documents, options) def _determineCallType(self, documents: Optional[List[ChatDocument]], operation_type: str) -> str: """ Determine call type based on documents and operation type. Criteria: no documents AND (operationType is "generate_plan" or "analyse_content") -> planning """ has_documents = documents is not None and len(documents) > 0 is_planning_operation = operation_type in [OperationType.GENERATE_PLAN, OperationType.ANALYSE_CONTENT] if not has_documents and is_planning_operation: return "planning" else: return "text" async def _callAiPlanning( self, prompt: str, placeholders: Optional[Dict[str, str]], options: AiCallOptions ) -> str: """ Handle planning calls with placeholder system and selective summarization. """ # Get available models for planning (text + reasoning capabilities) models = self._getModelsForOperation("planning", options) for model in models: try: # Build full prompt with placeholders full_prompt = self._buildPromptWithPlaceholders(prompt, placeholders) # Check size and reduce if needed if self._exceedsTokenLimit(full_prompt, model, options.safetyMargin): full_prompt = self._reducePlanningPrompt(full_prompt, placeholders, model, options) # Make AI call using existing callAiText result = await self.callAiText( prompt=full_prompt, documents=None, options=options ) return result except Exception as e: logger.warning(f"Planning model {model.name} failed: {e}") continue raise Exception("All planning models failed - check model availability and capabilities") async def _callAiText( self, prompt: str, documents: Optional[List[ChatDocument]], options: AiCallOptions ) -> str: """ Handle text calls with document processing through ExtractionService. """ # Get available models for text processing models = self._getModelsForOperation("text", options) for model in models: try: # Extract and process documents using ExtractionService context = "" if documents: # Convert ChatDocument to documentList format for ExtractionService documentList = [{ "id": d.id, "bytes": d.fileData, "fileName": d.fileName, "mimeType": d.mimeType } for d in documents] extracted_content = await self.extractionService.extractContent( documentList=documentList, options={ "prompt": prompt, "operationType": options.operationType, "processDocumentsIndividually": options.processDocumentsIndividually, "maxSize": options.maxContextBytes or int(model.maxTokens * 0.9), "chunkAllowed": not options.compressContext, "mergeStrategy": {"groupBy": "typeGroup"} } ) # Build context from list of ExtractedContent if isinstance(extracted_content, list): context = "\n\n---\n\n".join([ "\n\n".join([ p.data for p in ec.parts if p.typeGroup in ["text", "table", "structure"] and p.data ]) for ec in extracted_content ]) else: context = "" # Check size and reduce if needed full_prompt = prompt + "\n\n" + context if context else prompt if self._exceedsTokenLimit(full_prompt, model, options.safetyMargin): full_prompt = self._reduceTextPrompt(prompt, context, model, options) # Make AI call using existing callAiText result = await self.callAiText( prompt=full_prompt, documents=None, options=options ) return result except Exception as e: logger.warning(f"Text model {model.name} failed: {e}") continue raise Exception("All text models failed - check model availability and capabilities") def _getModelsForOperation(self, operation_type: str, options: AiCallOptions) -> List[ModelCapabilities]: """ Get models capable of handling the specific operation with capability filtering. """ # For now, return a default model - this will be enhanced with actual model registry default_model = ModelCapabilities( name="default", maxTokens=4000, capabilities=["text", "reasoning"] if operation_type == "planning" else ["text"], costPerToken=0.001, processingTime=1.0, isAvailable=True ) return [default_model] def _buildPromptWithPlaceholders(self, prompt: str, placeholders: Optional[Dict[str, str]]) -> str: """ Build full prompt by replacing placeholders with their content. Uses the new {{KEY:placeholder}} format. """ if not placeholders: return prompt full_prompt = prompt for placeholder, content in placeholders.items(): # Replace both old format {{placeholder}} and new format {{KEY:placeholder}} full_prompt = full_prompt.replace(f"{{{{{placeholder}}}}}", content) full_prompt = full_prompt.replace(f"{{{{KEY:{placeholder}}}}}", content) return full_prompt def _exceedsTokenLimit(self, text: str, model: ModelCapabilities, safety_margin: float) -> bool: """ Check if text exceeds model token limit with safety margin. """ # Simple character-based estimation (4 chars per token) estimated_tokens = len(text) // 4 max_tokens = int(model.maxTokens * (1 - safety_margin)) return estimated_tokens > max_tokens def _reducePlanningPrompt( self, full_prompt: str, placeholders: Optional[Dict[str, str]], model: ModelCapabilities, options: AiCallOptions ) -> str: """ Reduce planning prompt size by summarizing placeholders while preserving prompt structure. """ if not placeholders: return self._reduceText(full_prompt, 0.7) # Reduce placeholders while preserving prompt reduced_placeholders = {} for placeholder, content in placeholders.items(): if len(content) > 1000: # Only reduce long content reduction_factor = 0.7 reduced_content = self._reduceText(content, reduction_factor) reduced_placeholders[placeholder] = reduced_content else: reduced_placeholders[placeholder] = content return self._buildPromptWithPlaceholders(full_prompt, reduced_placeholders) def _reduceTextPrompt( self, prompt: str, context: str, model: ModelCapabilities, options: AiCallOptions ) -> str: """ Reduce text prompt size using typeGroup-aware chunking and merging. """ max_size = int(model.maxTokens * (1 - options.safetyMargin)) if options.compressPrompt: # Reduce both prompt and context target_size = max_size current_size = len(prompt) + len(context) reduction_factor = (target_size * 0.7) / current_size if reduction_factor < 1.0: prompt = self._reduceText(prompt, reduction_factor) context = self._reduceText(context, reduction_factor) else: # Only reduce context, preserve prompt integrity max_context_size = max_size - len(prompt) if len(context) > max_context_size: reduction_factor = max_context_size / len(context) context = self._reduceText(context, reduction_factor) return prompt + "\n\n" + context if context else prompt def _extractTextFromContentParts(self, extracted_content) -> str: """ Extract text content from ExtractionService ContentPart objects. """ if not extracted_content or not hasattr(extracted_content, 'parts'): return "" text_parts = [] for part in extracted_content.parts: if hasattr(part, 'typeGroup') and part.typeGroup in ['text', 'table', 'structure']: if hasattr(part, 'data') and part.data: text_parts.append(part.data) return "\n\n".join(text_parts) def _reduceText(self, text: str, reduction_factor: float) -> str: """ Reduce text size by the specified factor. """ if reduction_factor >= 1.0: return text target_length = int(len(text) * reduction_factor) return text[:target_length] + "... [reduced]"