521 lines
20 KiB
Python
521 lines
20 KiB
Python
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]"
|
|
|