diff --git a/modules/aicore/aicoreBase.py b/modules/aicore/aicoreBase.py index 0f0db21e..8cca3dfd 100644 --- a/modules/aicore/aicoreBase.py +++ b/modules/aicore/aicoreBase.py @@ -71,10 +71,10 @@ class BaseConnectorAi(ABC): models = self.getCachedModels() return [model for model in models if capability in model.capabilities] - def getModelsByTag(self, tag: str) -> List[AiModel]: - """Get models that have a specific tag.""" + def getModelsByPriority(self, priority: str) -> List[AiModel]: + """Get models that have a specific priority.""" models = self.getCachedModels() - return [model for model in models if tag in model.tags] + return [model for model in models if model.priority == priority] def getAvailableModels(self) -> List[AiModel]: """Get only available models.""" diff --git a/modules/aicore/aicoreModelRegistry.py b/modules/aicore/aicoreModelRegistry.py index 297bf52d..cf65e6d0 100644 --- a/modules/aicore/aicoreModelRegistry.py +++ b/modules/aicore/aicoreModelRegistry.py @@ -19,40 +19,40 @@ class ModelRegistry: def __init__(self): self._models: Dict[str, AiModel] = {} self._connectors: Dict[str, BaseConnectorAi] = {} - self._last_refresh: Optional[float] = None - self._refresh_interval: float = 300.0 # 5 minutes + self._lastRefresh: Optional[float] = None + self._refreshInterval: float = 300.0 # 5 minutes def registerConnector(self, connector: BaseConnectorAi): """Register a connector and collect its models.""" - connector_type = connector.getConnectorType() - self._connectors[connector_type] = connector + connectorType = connector.getConnectorType() + self._connectors[connectorType] = connector # Collect models from this connector try: models = connector.getCachedModels() for model in models: self._models[model.name] = model - logger.debug(f"Registered model: {model.name} from {connector_type}") + logger.debug(f"Registered model: {model.name} from {connectorType}") except Exception as e: - logger.error(f"Failed to register models from {connector_type}: {e}") + logger.error(f"Failed to register models from {connectorType}: {e}") def discoverConnectors(self) -> List[BaseConnectorAi]: """Auto-discover connectors by scanning aicorePlugin*.py files.""" connectors = [] - connector_dir = os.path.dirname(__file__) + connectorDir = os.path.dirname(__file__) # Scan for connector files - for filename in os.listdir(connector_dir): + for filename in os.listdir(connectorDir): if filename.startswith('aicorePlugin') and filename.endswith('.py'): - module_name = filename[:-3] # Remove .py extension + moduleName = filename[:-3] # Remove .py extension try: # Import the module - module = importlib.import_module(f'modules.connectors.{module_name}') + module = importlib.import_module(f'modules.connectors.{moduleName}') # Find connector classes (classes that inherit from BaseConnectorAi) - for attr_name in dir(module): - attr = getattr(module, attr_name) + for attrName in dir(module): + attr = getattr(module, attrName) if (isinstance(attr, type) and issubclass(attr, BaseConnectorAi) and attr != BaseConnectorAi): @@ -71,12 +71,12 @@ class ModelRegistry: """Refresh models from all registered connectors.""" import time - current_time = time.time() + currentTime = time.time() # Check if refresh is needed if (not force and - self._last_refresh is not None and - current_time - self._last_refresh < self._refresh_interval): + self._lastRefresh is not None and + currentTime - self._lastRefresh < self._refreshInterval): return logger.info("Refreshing model registry...") @@ -94,7 +94,7 @@ class ModelRegistry: except Exception as e: logger.error(f"Failed to refresh models from {connector.getConnectorType()}: {e}") - self._last_refresh = current_time + self._lastRefresh = currentTime logger.info(f"Model registry refreshed: {len(self._models)} models available") def getModel(self, name: str) -> Optional[AiModel]: @@ -107,29 +107,29 @@ class ModelRegistry: self.refreshModels() return list(self._models.values()) - def getModelsByConnector(self, connector_type: str) -> List[AiModel]: + def getModelsByConnector(self, connectorType: str) -> List[AiModel]: """Get models from a specific connector.""" self.refreshModels() - return [model for model in self._models.values() if model.connectorType == connector_type] + return [model for model in self._models.values() if model.connectorType == connectorType] def getModelsByCapability(self, capability: str) -> List[AiModel]: """Get models that support a specific capability.""" self.refreshModels() return [model for model in self._models.values() if capability in model.capabilities] - def getModelsByTag(self, tag: str) -> List[AiModel]: - """Get models that have a specific tag.""" + def getModelsByPriority(self, priority: str) -> List[AiModel]: + """Get models that have a specific priority.""" self.refreshModels() - return [model for model in self._models.values() if tag in model.tags] + return [model for model in self._models.values() if model.priority == priority] def getAvailableModels(self) -> List[AiModel]: """Get only available models.""" self.refreshModels() return [model for model in self._models.values() if model.isAvailable] - def getConnectorForModel(self, model_name: str) -> Optional[BaseConnectorAi]: + def getConnectorForModel(self, modelName: str) -> Optional[BaseConnectorAi]: """Get the connector instance for a specific model.""" - model = self.getModel(model_name) + model = self.getModel(modelName) if model: return self._connectors.get(model.connectorType) return None @@ -139,34 +139,34 @@ class ModelRegistry: self.refreshModels() stats = { - "total_models": len(self._models), - "available_models": len([m for m in self._models.values() if m.isAvailable]), + "totalModels": len(self._models), + "availableModels": len([m for m in self._models.values() if m.isAvailable]), "connectors": len(self._connectors), - "by_connector": {}, - "by_capability": {}, - "by_tag": {} + "byConnector": {}, + "byCapability": {}, + "byPriority": {} } # Count by connector for model in self._models.values(): connector = model.connectorType - if connector not in stats["by_connector"]: - stats["by_connector"][connector] = 0 - stats["by_connector"][connector] += 1 + if connector not in stats["byConnector"]: + stats["byConnector"][connector] = 0 + stats["byConnector"][connector] += 1 # Count by capability for model in self._models.values(): for capability in model.capabilities: - if capability not in stats["by_capability"]: - stats["by_capability"][capability] = 0 - stats["by_capability"][capability] += 1 + if capability not in stats["byCapability"]: + stats["byCapability"][capability] = 0 + stats["byCapability"][capability] += 1 - # Count by tag + # Count by priority for model in self._models.values(): - for tag in model.tags: - if tag not in stats["by_tag"]: - stats["by_tag"][tag] = 0 - stats["by_tag"][tag] += 1 + priority = model.priority + if priority not in stats["byPriority"]: + stats["byPriority"][priority] = 0 + stats["byPriority"][priority] += 1 return stats diff --git a/modules/aicore/aicoreModelSelectionConfig.py b/modules/aicore/aicoreModelSelectionConfig.py index 6f36e039..476dc527 100644 --- a/modules/aicore/aicoreModelSelectionConfig.py +++ b/modules/aicore/aicoreModelSelectionConfig.py @@ -3,24 +3,8 @@ Configuration for dynamic model selection rules. This makes model selection configurable rather than hardcoded. """ -from typing import Dict, List, Any, Optional -from dataclasses import dataclass -from modules.datamodels.datamodelAi import OperationType, Priority, ProcessingMode, ModelTags - - -@dataclass -class SelectionRule: - """A rule for model selection.""" - name: str - condition: str # Description of when this rule applies - weight: float # Weight for scoring (higher = more important) - operation_types: List[str] # Operation types this rule applies to - required_tags: List[str] # Required tags for this rule - preferred_tags: List[str] # Preferred tags for this rule - avoid_tags: List[str] # Tags to avoid for this rule - min_quality_rating: Optional[int] = None # Minimum quality rating - max_cost: Optional[float] = None # Maximum cost threshold - min_context_length: Optional[int] = None # Minimum context length required +from typing import Dict, List, Any +from modules.datamodels.datamodelAi import OperationTypeEnum, ModelCapabilitiesEnum, PriorityEnum, SelectionRule class ModelSelectionConfig: @@ -28,148 +12,142 @@ class ModelSelectionConfig: def __init__(self): self.rules = self._loadDefaultRules() - self.fallback_models = self._loadFallbackModels() + self.fallbackModels = self._loadFallbackModels() def _loadDefaultRules(self) -> List[SelectionRule]: """Load default selection rules.""" return [ # High quality for planning and analysis SelectionRule( - name="high_quality_analysis", + name="highQualityAnalysis", condition="Planning or analysis operations requiring high quality", weight=10.0, - operation_types=[OperationType.GENERATE_PLAN, OperationType.ANALYSE_CONTENT], - required_tags=[ModelTags.TEXT, ModelTags.REASONING, ModelTags.ANALYSIS], - preferred_tags=[ModelTags.HIGH_QUALITY], - avoid_tags=[ModelTags.FAST], - min_quality_rating=8 + operationTypes=[OperationTypeEnum.PLAN, OperationTypeEnum.ANALYSE], + priority=PriorityEnum.QUALITY, + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.REASONING, ModelCapabilitiesEnum.ANALYSIS], + minQualityRating=8 ), # Fast processing for basic operations SelectionRule( - name="fast_basic_processing", + name="fastBasicProcessing", condition="Basic operations requiring speed", weight=8.0, - operation_types=[OperationType.GENERAL], - required_tags=[ModelTags.TEXT, ModelTags.CHAT], - preferred_tags=[ModelTags.FAST], - avoid_tags=[], - min_quality_rating=5 + operationTypes=[OperationTypeEnum.GENERAL], + priority=PriorityEnum.SPEED, + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.CHAT], + minQualityRating=5 ), # Cost-effective for high-volume operations SelectionRule( - name="cost_effective_processing", + name="costEffectiveProcessing", condition="High-volume operations where cost matters", weight=7.0, - operation_types=[OperationType.GENERAL, OperationType.GENERATE_CONTENT], - required_tags=[ModelTags.TEXT], - preferred_tags=[ModelTags.COST_EFFECTIVE], - avoid_tags=[], - max_cost=0.01 # $0.01 per 1k tokens + operationTypes=[OperationTypeEnum.GENERAL, OperationTypeEnum.GENERATE], + priority=PriorityEnum.COST, + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION], + maxCost=0.01 # $0.01 per 1k tokens ), # Image analysis specific SelectionRule( - name="image_analysis", + name="imageAnalyse", condition="Image analysis operations", weight=10.0, - operation_types=[OperationType.IMAGE_ANALYSIS], - required_tags=[ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL], - preferred_tags=[ModelTags.HIGH_QUALITY], - avoid_tags=[], - min_quality_rating=8 + operationTypes=[OperationTypeEnum.IMAGE_ANALYSE], + priority=PriorityEnum.QUALITY, + capabilities=[ModelCapabilitiesEnum.VISION, ModelCapabilitiesEnum.MULTIMODAL], + minQualityRating=8 ), # Web research specific SelectionRule( - name="web_research", + name="webResearch", condition="Web research operations", weight=9.0, - operation_types=[OperationType.WEB_RESEARCH], - required_tags=[ModelTags.TEXT, ModelTags.ANALYSIS], - preferred_tags=[ModelTags.WEB, ModelTags.SEARCH], - avoid_tags=[], - min_quality_rating=7 + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + priority=PriorityEnum.BALANCED, + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.ANALYSIS, ModelCapabilitiesEnum.WEB_SEARCH], + minQualityRating=7 ), # Large context requirements SelectionRule( - name="large_context", + name="largeContext", condition="Operations requiring large context", weight=8.0, - operation_types=[OperationType.GENERAL, OperationType.ANALYSE_CONTENT], - required_tags=[ModelTags.TEXT], - preferred_tags=[], - avoid_tags=[], - min_context_length=100000 # 100k tokens + operationTypes=[OperationTypeEnum.GENERAL, OperationTypeEnum.ANALYSE], + priority=PriorityEnum.BALANCED, + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION], + minContextLength=100000 # 100k tokens ) ] def _loadFallbackModels(self) -> Dict[str, Dict[str, Any]]: """Load fallback model selection criteria.""" return { - OperationType.GENERAL: { - "priority_order": ["speed", "quality", "cost"], - "required_tags": [ModelTags.TEXT, ModelTags.CHAT], - "min_quality_rating": 5, - "max_cost_per_1k": 0.01 + OperationTypeEnum.GENERAL: { + "priorityOrder": ["speed", "quality", "cost"], + "operationTypes": [ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.CHAT], + "minQualityRating": 5, + "maxCostPer1k": 0.01 }, - OperationType.IMAGE_ANALYSIS: { - "priority_order": ["quality", "speed"], - "required_tags": [ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL], - "min_quality_rating": 8, - "max_cost_per_1k": 0.1 + OperationTypeEnum.IMAGE_ANALYSE: { + "priorityOrder": ["quality", "speed"], + "operationTypes": [ModelCapabilitiesEnum.VISION, ModelCapabilitiesEnum.MULTIMODAL], + "minQualityRating": 8, + "maxCostPer1k": 0.1 }, - OperationType.IMAGE_GENERATION: { - "priority_order": ["quality", "speed"], - "required_tags": [ModelTags.IMAGE_GENERATION, ModelTags.ART, ModelTags.VISUAL], - "min_quality_rating": 8, - "max_cost_per_1k": 0.1 + OperationTypeEnum.IMAGE_GENERATE: { + "priorityOrder": ["quality", "speed"], + "operationTypes": [ModelCapabilitiesEnum.IMAGE_GENERATE, ModelCapabilitiesEnum.ART, ModelCapabilitiesEnum.VISUAL_CREATION], + "minQualityRating": 8, + "maxCostPer1k": 0.1 }, - OperationType.WEB_RESEARCH: { - "priority_order": ["quality", "speed", "cost"], - "required_tags": [ModelTags.TEXT, ModelTags.ANALYSIS], - "preferred_tags": [ModelTags.WEB, ModelTags.SEARCH], - "min_quality_rating": 7, - "max_cost_per_1k": 0.02 + OperationTypeEnum.WEB_RESEARCH: { + "priorityOrder": ["quality", "speed", "cost"], + "operationTypes": [ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.ANALYSIS], + "preferredTags": [ModelCapabilitiesEnum.WEB_SEARCH], + "minQualityRating": 7, + "maxCostPer1k": 0.02 }, - OperationType.GENERATE_PLAN: { - "priority_order": ["quality", "speed"], - "required_tags": [ModelTags.TEXT, ModelTags.REASONING, ModelTags.ANALYSIS], - "preferred_tags": [ModelTags.HIGH_QUALITY], - "min_quality_rating": 8, - "max_cost_per_1k": 0.1 + OperationTypeEnum.PLAN: { + "priorityOrder": ["quality", "speed"], + "operationTypes": [ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.REASONING, ModelCapabilitiesEnum.ANALYSIS], + "preferredTags": [PriorityEnum.QUALITY], + "minQualityRating": 8, + "maxCostPer1k": 0.1 }, - OperationType.ANALYSE_CONTENT: { - "priority_order": ["quality", "speed"], - "required_tags": [ModelTags.TEXT, ModelTags.ANALYSIS, ModelTags.REASONING], - "preferred_tags": [ModelTags.HIGH_QUALITY], - "min_quality_rating": 8, - "max_cost_per_1k": 0.1 + OperationTypeEnum.ANALYSE: { + "priorityOrder": ["quality", "speed"], + "operationTypes": [ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.ANALYSIS, ModelCapabilitiesEnum.REASONING], + "preferredTags": [PriorityEnum.QUALITY], + "minQualityRating": 8, + "maxCostPer1k": 0.1 } } - def getRulesForOperation(self, operation_type: str) -> List[SelectionRule]: + def getRulesForOperation(self, operationType: str) -> List[SelectionRule]: """Get rules that apply to a specific operation type.""" - return [rule for rule in self.rules if operation_type in rule.operation_types] + return [rule for rule in self.rules if operationType in rule.operationTypes] - def getFallbackCriteria(self, operation_type: str) -> Dict[str, Any]: + def getFallbackCriteria(self, operationType: str) -> Dict[str, Any]: """Get fallback selection criteria for a specific operation type.""" - return self.fallback_models.get(operation_type, self.fallback_models[OperationType.GENERAL]) + return self.fallbackModels.get(operationType, self.fallbackModels[OperationTypeEnum.GENERAL]) def addRule(self, rule: SelectionRule): """Add a new selection rule.""" self.rules.append(rule) - def removeRule(self, rule_name: str): + def removeRule(self, ruleName: str): """Remove a selection rule by name.""" - self.rules = [rule for rule in self.rules if rule.name != rule_name] + self.rules = [rule for rule in self.rules if rule.name != ruleName] - def updateRule(self, rule_name: str, **kwargs): + def updateRule(self, ruleName: str, **kwargs): """Update an existing rule.""" for rule in self.rules: - if rule.name == rule_name: + if rule.name == ruleName: for key, value in kwargs.items(): if hasattr(rule, key): setattr(rule, key, value) diff --git a/modules/aicore/aicoreModelSelector.py b/modules/aicore/aicoreModelSelector.py index e4c6a36d..4f3de674 100644 --- a/modules/aicore/aicoreModelSelector.py +++ b/modules/aicore/aicoreModelSelector.py @@ -4,7 +4,7 @@ Dynamic model selector using configurable rules and scoring. import logging from typing import List, Optional, Dict, Any, Tuple -from modules.datamodels.datamodelAi import AiModel, AiCallOptions, OperationType, Priority, ProcessingMode, ModelTags +from modules.datamodels.datamodelAi import AiModel, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum, ModelCapabilitiesEnum from modules.aicore.aicoreModelSelectionConfig import model_selection_config logger = logging.getLogger(__name__) @@ -20,7 +20,7 @@ class ModelSelector: prompt: str, context: str, options: AiCallOptions, - available_models: List[AiModel]) -> Optional[AiModel]: + availableModels: List[AiModel]) -> Optional[AiModel]: """ Select the best model based on configurable rules and scoring. @@ -28,171 +28,171 @@ class ModelSelector: prompt: User prompt context: Context data options: AI call options - available_models: List of available models to choose from + availableModels: List of available models to choose from Returns: Selected model or None if no suitable model found """ - if not available_models: + if not availableModels: logger.warning("No models available for selection") return None logger.info(f"Selecting model for operation: {options.operationType}, priority: {options.priority}") # Calculate input size - input_size = len(prompt.encode("utf-8")) + len(context.encode("utf-8")) + inputSize = len(prompt.encode("utf-8")) + len(context.encode("utf-8")) # Get applicable rules rules = self.config.getRulesForOperation(options.operationType) logger.debug(f"Found {len(rules)} applicable rules for {options.operationType}") # Score each model - scored_models = [] - for model in available_models: + scoredModels = [] + for model in availableModels: if not model.isAvailable: continue - score = self._calculateModelScore(model, input_size, options, rules) + score = self._calculateModelScore(model, inputSize, options, rules) if score > 0: # Only consider models with positive scores - scored_models.append((model, score)) + scoredModels.append((model, score)) logger.debug(f"Model {model.name}: score={score:.2f}") - if not scored_models: + if not scoredModels: logger.warning("No models passed the selection criteria, trying fallback criteria") # Try fallback criteria - fallback_criteria = self.getFallbackCriteria(options.operationType) - return self._selectWithFallbackCriteria(available_models, fallback_criteria, input_size, options) + fallbackCriteria = self.getFallbackCriteria(options.operationType) + return self._selectWithFallbackCriteria(availableModels, fallbackCriteria, inputSize, options) # Sort by score (highest first) - scored_models.sort(key=lambda x: x[1], reverse=True) + scoredModels.sort(key=lambda x: x[1], reverse=True) - selected_model = scored_models[0][0] - selected_score = scored_models[0][1] + selectedModel = scoredModels[0][0] + selectedScore = scoredModels[0][1] - logger.info(f"Selected model: {selected_model.name} (score: {selected_score:.2f})") + logger.info(f"Selected model: {selectedModel.name} (score: {selectedScore:.2f})") # Log selection details - self._logSelectionDetails(selected_model, input_size, options) + self._logSelectionDetails(selectedModel, inputSize, options) - return selected_model + return selectedModel def _calculateModelScore(self, model: AiModel, - input_size: int, + inputSize: int, options: AiCallOptions, rules: List) -> float: """Calculate score for a model based on rules and criteria.""" score = 0.0 # Check basic requirements - if not self._meetsBasicRequirements(model, input_size, options): + if not self._meetsBasicRequirements(model, inputSize, options): return 0.0 # Apply rules for rule in rules: - rule_score = self._applyRule(model, input_size, options, rule) - score += rule_score * rule.weight + ruleScore = self._applyRule(model, inputSize, options, rule) + score += ruleScore * rule.weight # Apply priority-based scoring - priority_score = self._applyPriorityScoring(model, options) - score += priority_score + priorityScore = self._applyPriorityScoring(model, options) + score += priorityScore # Apply processing mode scoring - mode_score = self._applyProcessingModeScoring(model, options) - score += mode_score + modeScore = self._applyProcessingModeScoring(model, options) + score += modeScore # Apply cost constraints - if not self._meetsCostConstraints(model, input_size, options): + if not self._meetsCostConstraints(model, inputSize, options): score *= 0.1 # Heavily penalize but don't eliminate return max(0.0, score) - def _meetsBasicRequirements(self, model: AiModel, input_size: int, options: AiCallOptions) -> bool: + def _meetsBasicRequirements(self, model: AiModel, inputSize: int, options: AiCallOptions) -> bool: """Check if model meets basic requirements.""" # Context length check - if model.contextLength > 0 and input_size > model.contextLength * 0.8: - logger.debug(f"Model {model.name} rejected: input too large ({input_size} > {model.contextLength * 0.8})") + if model.contextLength > 0 and inputSize > model.contextLength * 0.8: + logger.debug(f"Model {model.name} rejected: input too large ({inputSize} > {model.contextLength * 0.8})") return False - # Required tags check - if options.requiredTags: - if not all(tag in model.tags for tag in options.requiredTags): - logger.debug(f"Model {model.name} rejected: missing required tags") + # Required operation types check + if options.operationTypes: + if not all(opType in model.operationTypes for opType in options.operationTypes): + logger.debug(f"Model {model.name} rejected: missing required operation types") return False # Capabilities check - if options.modelCapabilities: - if not all(cap in model.capabilities for cap in options.modelCapabilities): + if options.capabilities: + if not all(cap in model.capabilities for cap in options.capabilities): logger.debug(f"Model {model.name} rejected: missing required capabilities") return False - # Avoid tags check + # Avoid operation types check for rule in self.config.getRulesForOperation(options.operationType): - if any(tag in model.tags for tag in rule.avoid_tags): - logger.debug(f"Model {model.name} rejected: has avoid tags") + if any(opType in model.operationTypes for opType in rule.avoidOperationTypes): + logger.debug(f"Model {model.name} rejected: has avoid operation types") return False return True - def _applyRule(self, model: AiModel, input_size: int, options: AiCallOptions, rule) -> float: + def _applyRule(self, model: AiModel, inputSize: int, options: AiCallOptions, rule) -> float: """Apply a specific rule to calculate score contribution.""" score = 0.0 - # Required tags match - if all(tag in model.tags for tag in rule.required_tags): + # Required operation types match + if all(opType in model.operationTypes for opType in rule.operationTypes): score += 1.0 - # Preferred tags match - preferred_matches = sum(1 for tag in rule.preferred_tags if tag in model.tags) - if rule.preferred_tags: - score += (preferred_matches / len(rule.preferred_tags)) * 0.5 + # Preferred capabilities match + preferredMatches = sum(1 for cap in rule.preferredCapabilities if cap in model.capabilities) + if rule.preferredCapabilities: + score += (preferredMatches / len(rule.preferredCapabilities)) * 0.5 # Quality rating check - if rule.min_quality_rating and model.qualityRating >= rule.min_quality_rating: + if rule.minQualityRating and model.qualityRating >= rule.minQualityRating: score += 0.3 # Context length check - if rule.min_context_length and model.contextLength >= rule.min_context_length: + if rule.minContextLength and model.contextLength >= rule.minContextLength: score += 0.2 return score def _applyPriorityScoring(self, model: AiModel, options: AiCallOptions) -> float: """Apply priority-based scoring.""" - if options.priority == Priority.SPEED: + if options.priority == PriorityEnum.SPEED: return model.speedRating * 0.1 - elif options.priority == Priority.QUALITY: + elif options.priority == PriorityEnum.QUALITY: return model.qualityRating * 0.1 - elif options.priority == Priority.COST: + elif options.priority == PriorityEnum.COST: # Lower cost = higher score - cost_score = max(0, 1.0 - (model.costPer1kTokens * 1000)) - return cost_score * 0.1 + costScore = max(0, 1.0 - (model.costPer1kTokensInput * 1000)) + return costScore * 0.1 else: # BALANCED return (model.qualityRating + model.speedRating) * 0.05 def _applyProcessingModeScoring(self, model: AiModel, options: AiCallOptions) -> float: """Apply processing mode scoring.""" - if options.processingMode == ProcessingMode.DETAILED: - if ModelTags.HIGH_QUALITY in model.tags: + if options.processingMode == ProcessingModeEnum.DETAILED: + if model.priority == PriorityEnum.QUALITY: return 0.2 - elif options.processingMode == ProcessingMode.BASIC: - if ModelTags.FAST in model.tags: + elif options.processingMode == ProcessingModeEnum.BASIC: + if model.priority == PriorityEnum.SPEED: return 0.2 return 0.0 - def _meetsCostConstraints(self, model: AiModel, input_size: int, options: AiCallOptions) -> bool: + def _meetsCostConstraints(self, model: AiModel, inputSize: int, options: AiCallOptions) -> bool: """Check if model meets cost constraints.""" if options.maxCost is None: return True # Estimate cost - estimated_tokens = input_size / 4 - estimated_cost = (estimated_tokens / 1000) * model.costPer1kTokens + estimatedTokens = inputSize / 4 + estimatedCost = (estimatedTokens / 1000) * model.costPer1kTokensInput - return estimated_cost <= options.maxCost + return estimatedCost <= options.maxCost - def _logSelectionDetails(self, model: AiModel, input_size: int, options: AiCallOptions): + def _logSelectionDetails(self, model: AiModel, inputSize: int, options: AiCallOptions): """Log detailed selection information.""" logger.info(f"Model Selection Details:") logger.info(f" Selected: {model.displayName} ({model.name})") @@ -200,50 +200,50 @@ class ModelSelector: logger.info(f" Operation: {options.operationType}") logger.info(f" Priority: {options.priority}") logger.info(f" Processing Mode: {options.processingMode}") - logger.info(f" Input Size: {input_size} bytes") + logger.info(f" Input Size: {inputSize} bytes") logger.info(f" Context Length: {model.contextLength}") logger.info(f" Max Tokens: {model.maxTokens}") logger.info(f" Quality Rating: {model.qualityRating}/10") logger.info(f" Speed Rating: {model.speedRating}/10") - logger.info(f" Cost: ${model.costPer1kTokens:.4f}/1k tokens") + logger.info(f" Cost: ${model.costPer1kTokensInput:.4f}/1k tokens") logger.info(f" Capabilities: {', '.join(model.capabilities)}") - logger.info(f" Tags: {', '.join(model.tags)}") + logger.info(f" Priority: {model.priority}") - def getFallbackCriteria(self, operation_type: str) -> Dict[str, Any]: + def getFallbackCriteria(self, operationType: str) -> Dict[str, Any]: """Get fallback selection criteria for an operation type.""" - return self.config.getFallbackCriteria(operation_type) + return self.config.getFallbackCriteria(operationType) def _selectWithFallbackCriteria(self, - available_models: List[AiModel], - fallback_criteria: Dict[str, Any], - input_size: int, + availableModels: List[AiModel], + fallbackCriteria: Dict[str, Any], + inputSize: int, options: AiCallOptions) -> Optional[AiModel]: """Select model using fallback criteria when normal selection fails.""" logger.info("Using fallback criteria for model selection") # Filter models by fallback criteria candidates = [] - for model in available_models: + for model in availableModels: if not model.isAvailable: continue - # Check required tags - if fallback_criteria.get("required_tags"): - if not all(tag in model.tags for tag in fallback_criteria["required_tags"]): + # Check required operation types + if fallbackCriteria.get("operationTypes"): + if not all(opType in model.operationTypes for opType in fallbackCriteria["operationTypes"]): continue # Check quality rating - if fallback_criteria.get("min_quality_rating"): - if model.qualityRating < fallback_criteria["min_quality_rating"]: + if fallbackCriteria.get("minQualityRating"): + if model.qualityRating < fallbackCriteria["minQualityRating"]: continue # Check cost - if fallback_criteria.get("max_cost_per_1k"): - if model.costPer1kTokens > fallback_criteria["max_cost_per_1k"]: + if fallbackCriteria.get("maxCostPer1k"): + if model.costPer1kTokensInput > fallbackCriteria["maxCostPer1k"]: continue # Check context length - if model.contextLength > 0 and input_size > model.contextLength * 0.8: + if model.contextLength > 0 and inputSize > model.contextLength * 0.8: continue candidates.append(model) @@ -253,26 +253,133 @@ class ModelSelector: return None # Sort by priority order from fallback criteria - priority_order = fallback_criteria.get("priority_order", ["quality", "speed", "cost"]) + priorityOrder = fallbackCriteria.get("priorityOrder", ["quality", "speed", "cost"]) - def get_priority_score(model: AiModel) -> float: + def _getPriorityScore(model: AiModel) -> float: score = 0.0 - for i, priority in enumerate(priority_order): - weight = len(priority_order) - i # Higher weight for earlier priorities + for i, priority in enumerate(priorityOrder): + weight = len(priorityOrder) - i # Higher weight for earlier priorities if priority == "quality": score += model.qualityRating * weight elif priority == "speed": score += model.speedRating * weight elif priority == "cost": # Lower cost = higher score - score += (1.0 - model.costPer1kTokens * 1000) * weight + score += (1.0 - model.costPer1kTokensInput * 1000) * weight return score - candidates.sort(key=get_priority_score, reverse=True) - selected_model = candidates[0] + candidates.sort(key=_getPriorityScore, reverse=True) + selectedModel = candidates[0] - logger.info(f"Fallback selection: {selected_model.name} (score: {get_priority_score(selected_model):.2f})") - return selected_model + logger.info(f"Fallback selection: {selectedModel.name} (score: {_getPriorityScore(selectedModel):.2f})") + return selectedModel + + def getFallbackModels(self, + prompt: str, + context: str, + options: AiCallOptions, + availableModels: List[AiModel]) -> List[AiModel]: + """ + Get prioritized list of models for fallback sequence. + + Steps: + 1. Filter models by capability requirements + 2. Rate models by business requirements (priority, processing mode) + 3. Sort by rating (descending), then by cost (ascending) + + Args: + prompt: User prompt + context: Context data + options: AI call options + availableModels: List of available models + + Returns: + Prioritized list of models for fallback sequence + """ + if not availableModels: + logger.warning("No models available for fallback selection") + return [] + + logger.info(f"Building fallback sequence for operation: {options.operationType}, priority: {options.priority}") + + # Step 1: Filter by capability requirements + capableModels = self._filterByCapabilities(availableModels, options) + logger.info(f"Step 1 - Capable models: {[m.name for m in capableModels]}") + + if not capableModels: + logger.warning("No models meet capability requirements") + return [] + + # Step 2: Rate models by business requirements + ratedModels = self._rateModelsByBusinessRequirements(capableModels, prompt, context, options) + logger.info(f"Step 2 - Rated models: {[(m.name, rating) for m, rating in ratedModels]}") + + # Step 3: Sort by rating (descending), then by cost (ascending) + sortedModels = self._sortModelsByRatingAndCost(ratedModels) + logger.info(f"Step 3 - Sorted fallback sequence: {[m.name for m in sortedModels]}") + + return sortedModels + + def _filterByCapabilities(self, models: List[AiModel], options: AiCallOptions) -> List[AiModel]: + """Filter models by required capabilities.""" + capableModels = [] + + for model in models: + if not model.isAvailable: + continue + + # Check if model supports required capabilities + if options.capabilities: + if not all(cap in model.capabilities for cap in options.capabilities): + logger.debug(f"Model {model.name} missing required capabilities: {options.capabilities}") + continue + + # Check operation type compatibility + if not self._meetsBasicRequirements(model, options): + logger.debug(f"Model {model.name} doesn't meet basic requirements") + continue + + capableModels.append(model) + + return capableModels + + def _rateModelsByBusinessRequirements(self, + models: List[AiModel], + prompt: str, + context: str, + options: AiCallOptions) -> List[Tuple[AiModel, float]]: + """Rate models based on business requirements (priority, processing mode).""" + ratedModels = [] + inputSize = len(prompt.encode("utf-8")) + len(context.encode("utf-8")) + + for model in models: + # Base score from model selection logic + baseScore = self._calculateModelScore(model, inputSize, options, []) + + # Apply priority-based scoring + priorityScore = self._applyPriorityScoring(model, options) + + # Apply processing mode scoring + processingScore = self._applyProcessingModeScoring(model, options) + + # Combine scores + totalScore = baseScore + priorityScore + processingScore + + ratedModels.append((model, totalScore)) + logger.debug(f"Model {model.name}: base={baseScore:.2f}, priority={priorityScore:.2f}, processing={processingScore:.2f}, total={totalScore:.2f}") + + return ratedModels + + def _sortModelsByRatingAndCost(self, ratedModels: List[Tuple[AiModel, float]]) -> List[AiModel]: + """Sort models by rating (descending), then by cost (ascending).""" + def sortKey(item): + model, rating = item + # Primary sort: rating (descending) + # Secondary sort: cost (ascending) + return (-rating, model.costPer1kTokensInput) + + sortedItems = sorted(ratedModels, key=sortKey) + return [model for model, rating in sortedItems] # Global selector instance diff --git a/modules/aicore/aicorePluginAnthropic.py b/modules/aicore/aicorePluginAnthropic.py index 8dd2f9ed..7e11801f 100644 --- a/modules/aicore/aicorePluginAnthropic.py +++ b/modules/aicore/aicorePluginAnthropic.py @@ -5,7 +5,7 @@ from typing import Dict, Any, List, Union from fastapi import HTTPException from modules.shared.configuration import APP_CONFIG from modules.aicore.aicoreBase import BaseConnectorAi -from modules.datamodels.datamodelAi import AiModel, ModelTags +from modules.datamodels.datamodelAi import AiModel, ModelCapabilitiesEnum, PriorityEnum, ProcessingModeEnum, OperationTypeEnum # Configure logger logger = logging.getLogger(__name__) @@ -55,17 +55,17 @@ class AiAnthropic(BaseConnectorAi): connectorType="anthropic", maxTokens=200000, contextLength=200000, - costPer1kTokens=0.015, + costPer1kTokensInput=0.015, costPer1kTokensOutput=0.075, speedRating=7, qualityRating=10, - capabilities=["text_generation", "chat", "reasoning", "analysis"], - tags=[ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING, ModelTags.ANALYSIS, ModelTags.HIGH_QUALITY], + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.CHAT, ModelCapabilitiesEnum.REASONING, ModelCapabilitiesEnum.ANALYSIS], functionCall=self.callAiBasic, - priority="quality", - processingMode="detailed", - preferredFor=["generate_plan", "analyse_content"], - version="claude-3-5-sonnet-20241022" + priority=PriorityEnum.QUALITY, + processingMode=ProcessingModeEnum.DETAILED, + operationTypes=[OperationTypeEnum.PLAN, OperationTypeEnum.ANALYSE], + version="claude-3-5-sonnet-20241022", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 ), AiModel( name="anthropic_callAiImage", @@ -73,17 +73,17 @@ class AiAnthropic(BaseConnectorAi): connectorType="anthropic", maxTokens=200000, contextLength=200000, - costPer1kTokens=0.015, + costPer1kTokensInput=0.015, costPer1kTokensOutput=0.075, speedRating=7, qualityRating=10, - capabilities=["image_analysis", "vision", "multimodal"], - tags=[ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL, ModelTags.HIGH_QUALITY], + capabilities=[ModelCapabilitiesEnum.IMAGE_ANALYSE, ModelCapabilitiesEnum.VISION, ModelCapabilitiesEnum.MULTIMODAL], functionCall=self.callAiImage, - priority="quality", - processingMode="detailed", - preferredFor=["image_analysis"], - version="claude-3-5-sonnet-20241022" + priority=PriorityEnum.QUALITY, + processingMode=ProcessingModeEnum.DETAILED, + operationTypes=[OperationTypeEnum.IMAGE_ANALYSE], + version="claude-3-5-sonnet-20241022", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 ) ] diff --git a/modules/aicore/aicorePluginInternal.py b/modules/aicore/aicorePluginInternal.py new file mode 100644 index 00000000..baa686c5 --- /dev/null +++ b/modules/aicore/aicorePluginInternal.py @@ -0,0 +1,233 @@ +import logging +from typing import Dict, Any, List, Union +from modules.aicore.aicoreBase import BaseConnectorAi +from modules.datamodels.datamodelAi import AiModel, ModelCapabilitiesEnum, PriorityEnum, ProcessingModeEnum, OperationTypeEnum + +# Configure logger +logger = logging.getLogger(__name__) + +class AiInternal(BaseConnectorAi): + """Internal connector for document processing, generation, and rendering.""" + + def __init__(self): + super().__init__() + logger.info("Internal Connector initialized") + + def getConnectorType(self) -> str: + """Get the connector type identifier.""" + return "internal" + + def getModels(self) -> List[AiModel]: + """Get all available internal models.""" + return [ + AiModel( + name="internal_extraction", + displayName="Internal Document Extractor", + connectorType="internal", + maxTokens=0, # Not token-based + contextLength=0, + costPer1kTokensInput=0.0, + costPer1kTokensOutput=0.0, + speedRating=8, + qualityRating=8, + capabilities=[ModelCapabilitiesEnum.CONTENT_EXTRACTION, ModelCapabilitiesEnum.TEXT_EXTRACTION], + functionCall=self.extractDocument, + priority=PriorityEnum.COST, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.GENERAL], + version="internal-extractor-v1", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: 0.001 + (bytesSent + bytesReceived) / (1024 * 1024) * 0.01 + ), + AiModel( + name="internal_generation", + displayName="Internal Document Generator", + connectorType="internal", + maxTokens=0, # Not token-based + contextLength=0, + costPer1kTokensInput=0.0, + costPer1kTokensOutput=0.0, + speedRating=7, + qualityRating=8, + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.ANALYSIS], + functionCall=self.generateDocument, + priority=PriorityEnum.COST, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.GENERATE], + version="internal-generator-v1", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: 0.002 + (bytesReceived / (1024 * 1024)) * 0.005 + ), + AiModel( + name="internal_rendering", + displayName="Internal Document Renderer", + connectorType="internal", + maxTokens=0, # Not token-based + contextLength=0, + costPer1kTokensInput=0.0, + costPer1kTokensOutput=0.0, + speedRating=6, + qualityRating=9, + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.ANALYSIS], + functionCall=self.renderDocument, + priority=PriorityEnum.QUALITY, + processingMode=ProcessingModeEnum.DETAILED, + operationTypes=[OperationTypeEnum.GENERATE], + version="internal-renderer-v1", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: 0.003 + (bytesReceived / (1024 * 1024)) * 0.008 + ) + ] + + async def extractDocument(self, documentData: Union[str, bytes], extractionType: str = "basic") -> Dict[str, Any]: + """ + Extract content from a document. + + Args: + documentData: The document data to extract from + extractionType: Type of extraction (basic, advanced, detailed) + + Returns: + Dictionary with extraction results + """ + try: + logger.info(f"Starting document extraction with type: {extractionType}") + + # Simulate document extraction processing + # In a real implementation, this would use actual document processing libraries + + if isinstance(documentData, bytes): + content = documentData.decode('utf-8', errors='ignore') + else: + content = str(documentData) + + # Basic extraction logic + extractedContent = { + "text": content, + "metadata": { + "extraction_type": extractionType, + "content_length": len(content), + "processing_time": 0.1 # Simulated + } + } + + logger.info(f"Document extraction completed successfully") + return extractedContent + + except Exception as e: + logger.error(f"Error during document extraction: {str(e)}") + return { + "error": str(e), + "success": False + } + + async def generateDocument(self, template: str, data: Dict[str, Any], format: str = "html") -> Dict[str, Any]: + """ + Generate a document from a template and data. + + Args: + template: The document template + data: Data to populate the template + format: Output format (html, pdf, docx, etc.) + + Returns: + Dictionary with generated document + """ + try: + logger.info(f"Starting document generation with format: {format}") + + # Simulate document generation processing + # In a real implementation, this would use actual templating engines + + # Basic template processing + generatedContent = template + for key, value in data.items(): + placeholder = f"{{{key}}}" + generatedContent = generatedContent.replace(placeholder, str(value)) + + result = { + "content": generatedContent, + "format": format, + "metadata": { + "template_length": len(template), + "data_keys": list(data.keys()), + "processing_time": 0.2 # Simulated + } + } + + logger.info(f"Document generation completed successfully") + return result + + except Exception as e: + logger.error(f"Error during document generation: {str(e)}") + return { + "error": str(e), + "success": False + } + + async def renderDocument(self, content: str, targetFormat: str, options: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Render a document to a specific format. + + Args: + content: The content to render + targetFormat: Target format (html, pdf, docx, etc.) + options: Rendering options + + Returns: + Dictionary with rendered document + """ + try: + logger.info(f"Starting document rendering to format: {targetFormat}") + + if options is None: + options = {} + + # Simulate document rendering processing + # In a real implementation, this would use actual rendering libraries + + # Basic rendering logic based on target format + if targetFormat.lower() == "html": + renderedContent = f"{content}" + elif targetFormat.lower() == "pdf": + # Simulate PDF rendering + renderedContent = f"PDF_CONTENT_PLACEHOLDER: {content}" + else: + # Default to plain text + renderedContent = content + + result = { + "content": renderedContent, + "format": targetFormat, + "metadata": { + "input_length": len(content), + "output_length": len(renderedContent), + "processing_time": 0.3, # Simulated + "options": options + } + } + + logger.info(f"Document rendering completed successfully") + return result + + except Exception as e: + logger.error(f"Error during document rendering: {str(e)}") + return { + "error": str(e), + "success": False + } + + async def _testConnection(self) -> bool: + """ + Tests the internal processing capabilities. + + Returns: + True if internal processing is working, False otherwise + """ + try: + # Test basic functionality + testContent = "Test document content" + result = await self.extractDocument(testContent) + + return result.get("success", True) and "error" not in result + + except Exception as e: + logger.error(f"Internal connector test failed: {str(e)}") + return False diff --git a/modules/aicore/aicorePluginOpenai.py b/modules/aicore/aicorePluginOpenai.py index 89239ebb..b7429f5b 100644 --- a/modules/aicore/aicorePluginOpenai.py +++ b/modules/aicore/aicorePluginOpenai.py @@ -5,7 +5,7 @@ from typing import Dict, Any, List, Union from fastapi import HTTPException from modules.shared.configuration import APP_CONFIG from modules.aicore.aicoreBase import BaseConnectorAi -from modules.datamodels.datamodelAi import AiModel, ModelTags +from modules.datamodels.datamodelAi import AiModel, ModelCapabilitiesEnum, PriorityEnum, ProcessingModeEnum, OperationTypeEnum # Configure logger logger = logging.getLogger(__name__) @@ -57,17 +57,17 @@ class AiOpenai(BaseConnectorAi): connectorType="openai", maxTokens=128000, contextLength=128000, - costPer1kTokens=0.03, + costPer1kTokensInput=0.03, costPer1kTokensOutput=0.06, speedRating=8, qualityRating=9, - capabilities=["text_generation", "chat", "reasoning", "analysis"], - tags=[ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING, ModelTags.ANALYSIS], + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.CHAT, ModelCapabilitiesEnum.REASONING, ModelCapabilitiesEnum.ANALYSIS], functionCall=self.callAiBasic, - priority="balanced", - processingMode="advanced", - preferredFor=["general", "analyse_content"], - version="gpt-4o" + priority=PriorityEnum.BALANCED, + processingMode=ProcessingModeEnum.ADVANCED, + operationTypes=[OperationTypeEnum.GENERAL, OperationTypeEnum.ANALYSE], + version="gpt-4o", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.03 + (bytesReceived / 4 / 1000) * 0.06 ), AiModel( name="openai_callAiBasic_gpt35", @@ -75,17 +75,17 @@ class AiOpenai(BaseConnectorAi): connectorType="openai", maxTokens=16000, contextLength=16000, - costPer1kTokens=0.0015, + costPer1kTokensInput=0.0015, costPer1kTokensOutput=0.002, speedRating=9, qualityRating=7, - capabilities=["text_generation", "chat", "reasoning"], - tags=[ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING, ModelTags.FAST, ModelTags.COST_EFFECTIVE], + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.CHAT, ModelCapabilitiesEnum.REASONING], functionCall=self.callAiBasic, - priority="speed", - processingMode="basic", - preferredFor=["general"], - version="gpt-3.5-turbo" + priority=PriorityEnum.SPEED, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.GENERAL], + version="gpt-3.5-turbo", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.0015 + (bytesReceived / 4 / 1000) * 0.002 ), AiModel( name="openai_callAiImage", @@ -93,17 +93,17 @@ class AiOpenai(BaseConnectorAi): connectorType="openai", maxTokens=128000, contextLength=128000, - costPer1kTokens=0.03, + costPer1kTokensInput=0.03, costPer1kTokensOutput=0.06, speedRating=7, qualityRating=9, - capabilities=["image_analysis", "vision", "multimodal"], - tags=[ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL], + capabilities=[ModelCapabilitiesEnum.IMAGE_ANALYSE, ModelCapabilitiesEnum.VISION, ModelCapabilitiesEnum.MULTIMODAL], functionCall=self.callAiImage, - priority="quality", - processingMode="detailed", - preferredFor=["image_analysis"], - version="gpt-4o" + priority=PriorityEnum.QUALITY, + processingMode=ProcessingModeEnum.DETAILED, + operationTypes=[OperationTypeEnum.IMAGE_ANALYSE], + version="gpt-4o", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.03 + (bytesReceived / 4 / 1000) * 0.06 ), AiModel( name="openai_generateImage", @@ -111,17 +111,17 @@ class AiOpenai(BaseConnectorAi): connectorType="openai", maxTokens=0, # Image generation doesn't use tokens contextLength=0, - costPer1kTokens=0.04, + costPer1kTokensInput=0.04, costPer1kTokensOutput=0.0, speedRating=6, qualityRating=9, - capabilities=["image_generation", "art", "visual_creation"], - tags=[ModelTags.IMAGE_GENERATION, ModelTags.ART, ModelTags.VISUAL], + capabilities=[ModelCapabilitiesEnum.IMAGE_GENERATE, ModelCapabilitiesEnum.ART, ModelCapabilitiesEnum.VISUAL_CREATION], functionCall=self.generateImage, - priority="quality", - processingMode="detailed", - preferredFor=["image_generation"], - version="dall-e-3" + priority=PriorityEnum.QUALITY, + processingMode=ProcessingModeEnum.DETAILED, + operationTypes=[OperationTypeEnum.IMAGE_GENERATE], + version="dall-e-3", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.04 ) ] diff --git a/modules/aicore/aicorePluginPerplexity.py b/modules/aicore/aicorePluginPerplexity.py index 474756a0..9701039f 100644 --- a/modules/aicore/aicorePluginPerplexity.py +++ b/modules/aicore/aicorePluginPerplexity.py @@ -5,7 +5,7 @@ from typing import Dict, Any, List, Union, Optional from fastapi import HTTPException from modules.shared.configuration import APP_CONFIG from modules.aicore.aicoreBase import BaseConnectorAi -from modules.datamodels.datamodelAi import AiModel, ModelTags +from modules.datamodels.datamodelAi import AiModel, ModelCapabilitiesEnum, PriorityEnum, ProcessingModeEnum, OperationTypeEnum # Configure logger logger = logging.getLogger(__name__) @@ -55,17 +55,17 @@ class AiPerplexity(BaseConnectorAi): connectorType="perplexity", maxTokens=128000, contextLength=128000, - costPer1kTokens=0.005, + costPer1kTokensInput=0.005, costPer1kTokensOutput=0.005, speedRating=8, qualityRating=8, - capabilities=["text_generation", "chat", "reasoning", "web_search"], - tags=[ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING, ModelTags.WEB, ModelTags.SEARCH, ModelTags.COST_EFFECTIVE], + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.CHAT, ModelCapabilitiesEnum.REASONING, ModelCapabilitiesEnum.WEB_SEARCH], functionCall=self.callAiBasic, - priority="balanced", - processingMode="advanced", - preferredFor=["general", "web_research"], - version="llama-3.1-sonar-large-128k-online" + priority=PriorityEnum.BALANCED, + processingMode=ProcessingModeEnum.ADVANCED, + operationTypes=[OperationTypeEnum.GENERAL, OperationTypeEnum.WEB_RESEARCH], + version="llama-3.1-sonar-large-128k-online", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.005 ), AiModel( name="perplexity_callAiWithWebSearch", @@ -73,17 +73,17 @@ class AiPerplexity(BaseConnectorAi): connectorType="perplexity", maxTokens=128000, contextLength=128000, - costPer1kTokens=0.01, + costPer1kTokensInput=0.01, costPer1kTokensOutput=0.01, speedRating=7, qualityRating=9, - capabilities=["text_generation", "web_search", "research"], - tags=[ModelTags.TEXT, ModelTags.WEB, ModelTags.SEARCH, ModelTags.RESEARCH, ModelTags.HIGH_QUALITY], + capabilities=[ModelCapabilitiesEnum.TEXT_GENERATION, ModelCapabilitiesEnum.WEB_SEARCH, ModelCapabilitiesEnum.RESEARCH], functionCall=self.callAiWithWebSearch, - priority="quality", - processingMode="detailed", - preferredFor=["web_research"], - version="sonar-pro" + priority=PriorityEnum.QUALITY, + processingMode=ProcessingModeEnum.DETAILED, + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + version="sonar-pro", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.01 + (bytesReceived / 4 / 1000) * 0.01 ), AiModel( name="perplexity_researchTopic", @@ -91,17 +91,17 @@ class AiPerplexity(BaseConnectorAi): connectorType="perplexity", maxTokens=32000, contextLength=32000, - costPer1kTokens=0.002, + costPer1kTokensInput=0.002, costPer1kTokensOutput=0.002, speedRating=8, qualityRating=8, - capabilities=["web_search", "research", "information_gathering"], - tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.RESEARCH, ModelTags.INFORMATION, ModelTags.COST_EFFECTIVE], + capabilities=[ModelCapabilitiesEnum.WEB_SEARCH, ModelCapabilitiesEnum.RESEARCH, ModelCapabilitiesEnum.INFORMATION_GATHERING], functionCall=self.researchTopic, - priority="cost", - processingMode="basic", - preferredFor=["web_research"], - version="mistral-7b-instruct" + priority=PriorityEnum.COST, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + version="mistral-7b-instruct", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 ), AiModel( name="perplexity_answerQuestion", @@ -109,17 +109,17 @@ class AiPerplexity(BaseConnectorAi): connectorType="perplexity", maxTokens=32000, contextLength=32000, - costPer1kTokens=0.002, + costPer1kTokensInput=0.002, costPer1kTokensOutput=0.002, speedRating=8, qualityRating=8, - capabilities=["web_search", "question_answering", "research"], - tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.RESEARCH, ModelTags.COST_EFFECTIVE], + capabilities=[ModelCapabilitiesEnum.WEB_SEARCH, ModelCapabilitiesEnum.QUESTION_ANSWERING, ModelCapabilitiesEnum.RESEARCH], functionCall=self.answerQuestion, - priority="cost", - processingMode="basic", - preferredFor=["web_research"], - version="mistral-7b-instruct" + priority=PriorityEnum.COST, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + version="mistral-7b-instruct", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 ), AiModel( name="perplexity_getCurrentNews", @@ -127,17 +127,17 @@ class AiPerplexity(BaseConnectorAi): connectorType="perplexity", maxTokens=32000, contextLength=32000, - costPer1kTokens=0.002, + costPer1kTokensInput=0.002, costPer1kTokensOutput=0.002, speedRating=8, qualityRating=8, - capabilities=["web_search", "news", "current_events"], - tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.COST_EFFECTIVE], + capabilities=[ModelCapabilitiesEnum.WEB_SEARCH, ModelCapabilitiesEnum.NEWS, ModelCapabilitiesEnum.CURRENT_EVENTS], functionCall=self.getCurrentNews, - priority="cost", - processingMode="basic", - preferredFor=["web_research"], - version="mistral-7b-instruct" + priority=PriorityEnum.COST, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + version="mistral-7b-instruct", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 ) ] diff --git a/modules/aicore/aicorePluginTavily.py b/modules/aicore/aicorePluginTavily.py index 5d6b4e20..73966dca 100644 --- a/modules/aicore/aicorePluginTavily.py +++ b/modules/aicore/aicorePluginTavily.py @@ -9,7 +9,7 @@ from tavily import AsyncTavilyClient from modules.shared.configuration import APP_CONFIG from modules.shared.timezoneUtils import get_utc_timestamp from modules.aicore.aicoreBase import BaseConnectorAi -from modules.datamodels.datamodelAi import AiModel, ModelTags +from modules.datamodels.datamodelAi import AiModel, ModelCapabilitiesEnum, PriorityEnum, ProcessingModeEnum, OperationTypeEnum from modules.datamodels.datamodelWeb import ( WebSearchActionResult, WebSearchActionDocument, @@ -46,9 +46,9 @@ class ConnectorWeb(BaseConnectorAi): super().__init__() self.client: Optional[AsyncTavilyClient] = None # Cached settings loaded at initialization time - self.crawl_timeout: int = 30 - self.crawl_max_retries: int = 3 - self.crawl_retry_delay: int = 2 + self.crawlTimeout: int = 30 + self.crawlMaxRetries: int = 3 + self.crawlRetryDelay: int = 2 # Cached web search constraints (camelCase per project style) self.webSearchMinResults: int = 1 self.webSearchMaxResults: int = 20 @@ -66,17 +66,17 @@ class ConnectorWeb(BaseConnectorAi): connectorType="tavily", maxTokens=0, # Web search doesn't use tokens contextLength=0, - costPer1kTokens=0.0, + costPer1kTokensInput=0.0, costPer1kTokensOutput=0.0, speedRating=8, qualityRating=8, - capabilities=["web_search", "information_retrieval", "url_discovery"], - tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.INFORMATION], + capabilities=[ModelCapabilitiesEnum.WEB_SEARCH, ModelCapabilitiesEnum.INFORMATION_RETRIEVAL, ModelCapabilitiesEnum.URL_DISCOVERY], functionCall=self.search, - priority="balanced", - processingMode="basic", - preferredFor=["web_research"], - version="tavily-search" + priority=PriorityEnum.BALANCED, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + version="tavily-search", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived, searchDepth="basic", numRequests=1: numRequests * (1 if searchDepth == "basic" else 2) * 0.008 ), AiModel( name="tavily_extract", @@ -84,17 +84,17 @@ class ConnectorWeb(BaseConnectorAi): connectorType="tavily", maxTokens=0, # Web extraction doesn't use tokens contextLength=0, - costPer1kTokens=0.0, + costPer1kTokensInput=0.0, costPer1kTokensOutput=0.0, speedRating=6, qualityRating=8, - capabilities=["web_crawling", "content_extraction", "text_extraction"], - tags=[ModelTags.WEB, ModelTags.EXTRACT, ModelTags.CONTENT], + capabilities=[ModelCapabilitiesEnum.WEB_CRAWLING, ModelCapabilitiesEnum.CONTENT_EXTRACTION, ModelCapabilitiesEnum.TEXT_EXTRACTION], functionCall=self.crawl, - priority="balanced", - processingMode="basic", - preferredFor=["web_research"], - version="tavily-extract" + priority=PriorityEnum.BALANCED, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + version="tavily-extract", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived, extractionDepth="basic", numSuccessfulUrls=1: (numSuccessfulUrls / 5) * (1 if extractionDepth == "basic" else 2) * 0.008 ), AiModel( name="tavily_crawl", @@ -102,17 +102,17 @@ class ConnectorWeb(BaseConnectorAi): connectorType="tavily", maxTokens=0, # Web crawling doesn't use tokens contextLength=0, - costPer1kTokens=0.0, + costPer1kTokensInput=0.0, costPer1kTokensOutput=0.0, speedRating=6, qualityRating=8, - capabilities=["web_crawling", "content_extraction", "mapping"], - tags=[ModelTags.WEB, ModelTags.CRAWL, ModelTags.EXTRACT], + capabilities=[ModelCapabilitiesEnum.WEB_CRAWLING, ModelCapabilitiesEnum.CONTENT_EXTRACTION, ModelCapabilitiesEnum.MAPPING], functionCall=self.crawl, - priority="balanced", - processingMode="basic", - preferredFor=["web_research"], - version="tavily-crawl" + priority=PriorityEnum.BALANCED, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + version="tavily-crawl", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived, numPages=10, extractionDepth="basic", withInstructions=False, numSuccessfulExtractions=10: ((numPages / 10) * (2 if withInstructions else 1) + (numSuccessfulExtractions / 5) * (1 if extractionDepth == "basic" else 2)) * 0.008 ), AiModel( name="tavily_scrape", @@ -120,17 +120,17 @@ class ConnectorWeb(BaseConnectorAi): connectorType="tavily", maxTokens=0, # Web scraping doesn't use tokens contextLength=0, - costPer1kTokens=0.0, + costPer1kTokensInput=0.0, costPer1kTokensOutput=0.0, speedRating=6, qualityRating=8, - capabilities=["web_search", "web_crawling", "content_extraction", "information_retrieval"], - tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.CRAWL, ModelTags.EXTRACT, ModelTags.CONTENT, ModelTags.INFORMATION], + capabilities=[ModelCapabilitiesEnum.WEB_SEARCH, ModelCapabilitiesEnum.WEB_CRAWLING, ModelCapabilitiesEnum.CONTENT_EXTRACTION, ModelCapabilitiesEnum.INFORMATION_RETRIEVAL], functionCall=self.scrape, - priority="balanced", - processingMode="basic", - preferredFor=["web_research"], - version="tavily-search-extract" + priority=PriorityEnum.BALANCED, + processingMode=ProcessingModeEnum.BASIC, + operationTypes=[OperationTypeEnum.WEB_RESEARCH], + version="tavily-search-extract", + calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived, searchDepth="basic", numSuccessfulUrls=1, extractionDepth="basic": ((1 if searchDepth == "basic" else 2) + (numSuccessfulUrls / 5) * (1 if extractionDepth == "basic" else 2)) * 0.008 ) ] @@ -140,14 +140,14 @@ class ConnectorWeb(BaseConnectorAi): if not api_key: raise ValueError("Tavily API key not configured. Please set Connector_WebTavily_API_KEY_SECRET in config.ini") # Load and cache web crawl related configuration - crawl_timeout = int(APP_CONFIG.get("Web_Crawl_TIMEOUT", "30")) - crawl_max_retries = int(APP_CONFIG.get("Web_Crawl_MAX_RETRIES", "3")) - crawl_retry_delay = int(APP_CONFIG.get("Web_Crawl_RETRY_DELAY", "2")) + crawlTimeout = int(APP_CONFIG.get("Web_Crawl_TIMEOUT", "30")) + crawlMaxRetries = int(APP_CONFIG.get("Web_Crawl_MAX_RETRIES", "3")) + crawlRetryDelay = int(APP_CONFIG.get("Web_Crawl_RETRY_DELAY", "2")) return cls( client=AsyncTavilyClient(api_key=api_key), - crawl_timeout=crawl_timeout, - crawl_max_retries=crawl_max_retries, - crawl_retry_delay=crawl_retry_delay, + crawlTimeout=crawlTimeout, + crawlMaxRetries=crawlMaxRetries, + crawlRetryDelay=crawlRetryDelay, webSearchMinResults=int(APP_CONFIG.get("Web_Search_MIN_RESULTS", "1")), webSearchMaxResults=int(APP_CONFIG.get("Web_Search_MAX_RESULTS", "20")), ) @@ -363,10 +363,10 @@ class ConnectorWeb(BaseConnectorAi): ) -> list[WebSearchResult]: """Calls the Tavily API to perform a web search.""" # Make sure max_results is within the allowed range (use cached values) - min_results = self.webSearchMinResults - max_allowed_results = self.webSearchMaxResults - if max_results < min_results or max_results > max_allowed_results: - raise ValueError(f"max_results must be between {min_results} and {max_allowed_results}") + minResults = self.webSearchMinResults + maxAllowedResults = self.webSearchMaxResults + if max_results < minResults or max_results > maxAllowedResults: + raise ValueError(f"max_results must be between {minResults} and {maxAllowedResults}") # Perform actual API call # Build kwargs only for provided options to avoid API rejections @@ -409,16 +409,16 @@ class ConnectorWeb(BaseConnectorAi): format: str | None = None, ) -> list[WebCrawlResult]: """Calls the Tavily API to extract text content from URLs with retry logic.""" - max_retries = self.crawl_max_retries - retry_delay = self.crawl_retry_delay - timeout = self.crawl_timeout + maxRetries = self.crawlMaxRetries + retryDelay = self.crawlRetryDelay + timeout = self.crawlTimeout logger.debug(f"Starting crawl of {len(urls)} URLs: {urls}") logger.debug(f"Crawl settings: extract_depth={extract_depth}, format={format}, timeout={timeout}s") - for attempt in range(max_retries + 1): + for attempt in range(maxRetries + 1): try: - logger.debug(f"Crawl attempt {attempt + 1}/{max_retries + 1}") + logger.debug(f"Crawl attempt {attempt + 1}/{maxRetries + 1}") # Use asyncio.wait_for for timeout # Build kwargs for extract @@ -460,11 +460,11 @@ class ConnectorWeb(BaseConnectorAi): except asyncio.TimeoutError: logger.warning(f"Crawl attempt {attempt + 1} timed out after {timeout} seconds for URLs: {urls}") - if attempt < max_retries: - logger.info(f"Retrying in {retry_delay} seconds...") - await asyncio.sleep(retry_delay) + if attempt < maxRetries: + logger.info(f"Retrying in {retryDelay} seconds...") + await asyncio.sleep(retryDelay) else: - raise Exception(f"Crawl failed after {max_retries + 1} attempts due to timeout") + raise Exception(f"Crawl failed after {maxRetries + 1} attempts due to timeout") except Exception as e: logger.warning(f"Crawl attempt {attempt + 1} failed for URLs {urls}: {str(e)}") @@ -483,8 +483,8 @@ class ConnectorWeb(BaseConnectorAi): if len(url) > 2000: logger.debug(f" WARNING: URL is very long ({len(url)} chars)") - if attempt < max_retries: - logger.info(f"Retrying in {retry_delay} seconds...") - await asyncio.sleep(retry_delay) + if attempt < maxRetries: + logger.info(f"Retrying in {retryDelay} seconds...") + await asyncio.sleep(retryDelay) else: - raise Exception(f"Crawl failed after {max_retries + 1} attempts: {str(e)}") + raise Exception(f"Crawl failed after {maxRetries + 1} attempts: {str(e)}") diff --git a/modules/datamodels/datamodelAi.py b/modules/datamodels/datamodelAi.py index a492c690..8487744f 100644 --- a/modules/datamodels/datamodelAi.py +++ b/modules/datamodels/datamodelAi.py @@ -1,86 +1,65 @@ from typing import Optional, List, Dict, Any, Literal, Callable from pydantic import BaseModel, Field - +from enum import Enum # Operation Types -class OperationType: +class OperationTypeEnum(str, Enum): GENERAL = "general" - GENERATE_PLAN = "generate_plan" - ANALYSE_CONTENT = "analyse_content" - GENERATE_CONTENT = "generate_content" - WEB_RESEARCH = "web_research" - IMAGE_ANALYSIS = "image_analysis" - IMAGE_GENERATION = "image_generation" + PLAN = "plan" + ANALYSE = "analyse" + GENERATE = "generate" + WEB_RESEARCH = "webResearch" + IMAGE_ANALYSE = "imageAnalyse" + IMAGE_GENERATE = "imageGenerate" # Processing Modes -class ProcessingMode: +class ProcessingModeEnum(str, Enum): BASIC = "basic" ADVANCED = "advanced" DETAILED = "detailed" - # Priority Levels -class Priority: +class PriorityEnum(str, Enum): SPEED = "speed" QUALITY = "quality" COST = "cost" BALANCED = "balanced" -# Model Tags -class ModelTags: - # Core capabilities - TEXT = "text" +# Model Capabilities Enumeration +class ModelCapabilitiesEnum(str, Enum): + # Text generation capabilities + TEXT_GENERATION = "text_generation" CHAT = "chat" REASONING = "reasoning" ANALYSIS = "analysis" - IMAGE = "image" + + # Image capabilities + IMAGE_ANALYSE = "imageAnalyse" + IMAGE_GENERATE = "imageGenerate" VISION = "vision" MULTIMODAL = "multimodal" - WEB = "web" - SEARCH = "search" - CRAWL = "crawl" - EXTRACT = "extract" - CONTENT = "content" - INFORMATION = "information" - - # Quality indicators - HIGH_QUALITY = "high_quality" - FAST = "fast" - COST_EFFECTIVE = "cost_effective" - GENERAL = "general" - - # Specialized capabilities - IMAGE_GENERATION = "image_generation" ART = "art" - VISUAL = "visual" - VARIATIONS = "variations" - API = "api" - INFO = "info" - MODELS = "models" - - -# Operation Type to Required Tags Mapping -OPERATION_TAG_MAPPING = { - OperationType.GENERAL: [ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING], - OperationType.GENERATE_PLAN: [ModelTags.TEXT, ModelTags.REASONING, ModelTags.ANALYSIS], - OperationType.ANALYSE_CONTENT: [ModelTags.TEXT, ModelTags.ANALYSIS, ModelTags.REASONING], - OperationType.GENERATE_CONTENT: [ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING], - OperationType.WEB_RESEARCH: [ModelTags.TEXT, ModelTags.ANALYSIS, ModelTags.REASONING], - OperationType.IMAGE_ANALYSIS: [ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL], - OperationType.IMAGE_GENERATION: [ModelTags.IMAGE_GENERATION, ModelTags.ART, ModelTags.VISUAL], -} - - -# Processing Mode to Priority Mapping -PROCESSING_MODE_PRIORITY_MAPPING = { - ProcessingMode.BASIC: Priority.SPEED, - ProcessingMode.ADVANCED: Priority.BALANCED, - ProcessingMode.DETAILED: Priority.QUALITY, -} - + VISUAL_CREATION = "visual_creation" + + # Web capabilities + WEB_SEARCH = "web_search" + WEB_CRAWLING = "web_crawling" + CONTENT_EXTRACTION = "content_extraction" + TEXT_EXTRACTION = "text_extraction" + INFORMATION_RETRIEVAL = "information_retrieval" + URL_DISCOVERY = "url_discovery" + MAPPING = "mapping" + + # Research capabilities + RESEARCH = "research" + QUESTION_ANSWERING = "question_answering" + INFORMATION_GATHERING = "information_gathering" + NEWS = "news" + CURRENT_EVENTS = "current_events" + class AiModel(BaseModel): """Enhanced AI model definition with dynamic capabilities.""" @@ -94,75 +73,67 @@ class AiModel(BaseModel): contextLength: int = Field(description="Maximum context length this model can handle") # Cost information - costPer1kTokens: float = Field(default=0.0, description="Cost per 1000 input tokens") + costPer1kTokensInput: float = Field(default=0.0, description="Cost per 1000 input tokens") costPer1kTokensOutput: float = Field(default=0.0, description="Cost per 1000 output tokens") # Performance ratings speedRating: int = Field(ge=1, le=10, description="Speed rating (1-10, higher = faster)") qualityRating: int = Field(ge=1, le=10, description="Quality rating (1-10, higher = better)") - - # Capabilities and tags - capabilities: List[str] = Field(description="List of model capabilities") - tags: List[str] = Field(description="List of model tags for filtering") - + # Function reference (not serialized) functionCall: Optional[Callable] = Field(default=None, exclude=True, description="Function to call for this model") + calculatePriceUsd: Optional[Callable] = Field(default=None, exclude=True, description="Function to calculate price in USD") # Selection criteria - priority: str = Field(default="balanced", description="Default priority for this model") - processingMode: str = Field(default="basic", description="Default processing mode") - isAvailable: bool = Field(default=True, description="Whether model is currently available") - - # Advanced selection criteria + capabilities: List[ModelCapabilitiesEnum] = Field(description="List of model capabilities. See ModelCapabilitiesEnum enum for available values.") + priority: PriorityEnum = Field(default=PriorityEnum.BALANCED, description="Default priority for this model. See PriorityEnum for available values.") + processingMode: ProcessingModeEnum = Field(default=ProcessingModeEnum.BASIC, description="Default processing mode. See ProcessingModeEnum for available values.") + operationTypes: List[OperationTypeEnum] = Field(default=[], description="Operation types this model should avoid") minContextLength: Optional[int] = Field(default=None, description="Minimum context length required") - maxCost: Optional[float] = Field(default=None, description="Maximum cost this model should be used for") - preferredFor: List[str] = Field(default=[], description="Operation types this model is preferred for") - avoidFor: List[str] = Field(default=[], description="Operation types this model should avoid") + isAvailable: bool = Field(default=True, description="Whether model is currently available") # Metadata version: Optional[str] = Field(default=None, description="Model version") lastUpdated: Optional[str] = Field(default=None, description="Last update timestamp") class Config: - arbitrary_types_allowed = True # Allow Callable type + arbitraryTypesAllowed = True # Allow Callable type -class ModelCapabilities(BaseModel): - """Model capabilities and characteristics for dynamic selection.""" - - name: str = Field(description="Model name/identifier") - maxTokens: int = Field(description="Maximum token limit for this model") - capabilities: List[str] = Field(description="List of capabilities: text, image, vision, reasoning, analysis, etc.") - costPerToken: float = Field(default=0.0, description="Cost per token (if available)") - processingTime: float = Field(default=1.0, description="Average processing time multiplier") - isAvailable: bool = Field(default=True, description="Whether model is currently available") +class SelectionRule(BaseModel): + """A rule for model selection.""" + name: str = Field(description="Rule name identifier") + condition: str = Field(description="Description of when this rule applies") + weight: float = Field(description="Weight for scoring (higher = more important)") + operationTypes: List[OperationTypeEnum] = Field(description="Operation types this rule applies to") + priority: PriorityEnum = Field(default=PriorityEnum.BALANCED, description="Priority level for this rule") + capabilities: List[ModelCapabilitiesEnum] = Field(default=[], description="Required capabilities for this rule") + minQualityRating: Optional[int] = Field(default=None, description="Minimum quality rating") + maxCost: Optional[float] = Field(default=None, description="Maximum cost threshold") + minContextLength: Optional[int] = Field(default=None, description="Minimum context length required") class AiCallOptions(BaseModel): """Options for centralized AI processing with clear operation types and tags.""" - - operationType: str = Field(default="general", description="Type of operation: general, generate_plan, analyse_content, generate_content, web_research") - priority: str = Field(default="balanced", description="speed|quality|cost|balanced") + operationType: OperationTypeEnum = Field(default=OperationTypeEnum.GENERAL, description="Type of operation") + priority: PriorityEnum = Field(default=PriorityEnum.BALANCED, description="Priority level") compressPrompt: bool = Field(default=True, description="Whether to compress the prompt") compressContext: bool = Field(default=True, description="If False: process each chunk; If True: summarize and work on summary") processDocumentsIndividually: bool = Field(default=True, description="If True, process each document separately; else pool docs") maxContextBytes: Optional[int] = Field(default=None, description="Hard cap for extracted context size passed to the model") maxCost: Optional[float] = Field(default=None, description="Max cost budget") maxProcessingTime: Optional[int] = Field(default=None, description="Max processing time in seconds") - requiredTags: Optional[List[str]] = Field(default=None, description="Required model tags for selection") - processingMode: str = Field(default="basic", description="Processing mode: basic, advanced, detailed") + processingMode: ProcessingModeEnum = Field(default=ProcessingModeEnum.BASIC, description="Processing mode") resultFormat: Optional[str] = Field(default=None, description="Expected result format: txt, json, csv, xml, etc.") - # New fields for dynamic strategy - callType: Literal["planning", "text"] = Field(default="text", description="Call type: planning or text") safetyMargin: float = Field(default=0.1, ge=0.0, le=0.5, description="Safety margin for token limits (0.0-0.5)") - modelCapabilities: Optional[List[str]] = Field(default=None, description="Required model capabilities for filtering") + capabilities: Optional[List[ModelCapabilitiesEnum]] = Field(default=None, description="Required model capabilities for filtering") # Model generation parameters temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0, description="Temperature for response generation (0.0-2.0, lower = more consistent)") maxTokens: Optional[int] = Field(default=None, ge=1, le=32000, description="Maximum tokens in response") maxParts: Optional[int] = Field(default=1000, ge=1, le=1000, description="Maximum number of continuation parts to fetch") - + class AiCallRequest(BaseModel): """Centralized AI call request payload for interface use.""" diff --git a/modules/interfaces/_BACKUP_NOT_USED_interfaceAiObjects.py b/modules/interfaces/_BACKUP_NOT_USED_interfaceAiObjects.py deleted file mode 100644 index 8f0fc0d0..00000000 --- a/modules/interfaces/_BACKUP_NOT_USED_interfaceAiObjects.py +++ /dev/null @@ -1,1372 +0,0 @@ -import logging -import asyncio -from typing import Dict, Any, List, Union, Tuple, Optional -from dataclasses import dataclass -import time - -logger = logging.getLogger(__name__) - -from modules.connectors.aicorePluginOpenai import AiOpenai -from modules.connectors.aicorePluginAnthropic import AiAnthropic -from modules.connectors.aicorePluginPerplexity import AiPerplexity -from modules.connectors.aicorePluginTavily import ConnectorWeb -from modules.datamodels.datamodelAi import ( - AiCallOptions, - AiCallRequest, - AiCallResponse, - OperationType, - ProcessingMode, - Priority, - ModelTags, - OPERATION_TAG_MAPPING, - PROCESSING_MODE_PRIORITY_MAPPING -) -from modules.datamodels.datamodelWeb import ( - WebResearchRequest, - WebResearchActionResult, - WebSearchResultItem, - WebCrawlResultItem, - WebSearchRequest, - WebCrawlRequest, -) -from modules.datamodels.datamodelChat import ActionDocument - - -# Comprehensive model registry with capability tags and function mapping -aiModels: Dict[str, Dict[str, Any]] = { - # OpenAI Models - "openai_callAiBasic": { - "connector": "openai", - "function": "callAiBasic", - "llmName": "gpt-4o", - "contextLength": 128000, - "costPer1kTokens": 0.03, - "costPer1kTokensOutput": 0.06, - "speedRating": 8, - "qualityRating": 9, - "capabilities": ["text_generation", "chat", "reasoning", "analysis"], - "tags": ["text", "chat", "reasoning", "analysis", "general"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.03 + (bytesReceived / 4 / 1000) * 0.06 - }, - "openai_callAiBasic_gpt35": { - "connector": "openai", - "function": "callAiBasic", - "llmName": "gpt-3.5-turbo", - "contextLength": 16000, - "costPer1kTokens": 0.0015, - "costPer1kTokensOutput": 0.002, - "speedRating": 9, - "qualityRating": 7, - "capabilities": ["text_generation", "chat", "reasoning"], - "tags": ["text", "chat", "reasoning", "general", "fast"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.0015 + (bytesReceived / 4 / 1000) * 0.002 - }, - "openai_callAiImage": { - "connector": "openai", - "function": "callAiImage", - "llmName": "gpt-4o", - "contextLength": 128000, - "costPer1kTokens": 0.03, - "costPer1kTokensOutput": 0.06, - "speedRating": 7, - "qualityRating": 9, - "capabilities": ["image_analysis", "vision", "multimodal"], - "tags": ["image", "vision", "multimodal"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.03 + (bytesReceived / 4 / 1000) * 0.06 - }, - "openai_generateImage": { - "connector": "openai", - "function": "generateImage", - "llmName": "dall-e-3", - "contextLength": 0, - "costPer1kTokens": 0.04, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 9, - "capabilities": ["image_generation", "art", "visual_creation"], - "tags": ["image_generation", "art", "visual"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.04 - }, - - # Anthropic Models - "anthropic_callAiBasic": { - "connector": "anthropic", - "function": "callAiBasic", - "llmName": "claude-3-5-sonnet-20241022", - "contextLength": 200000, - "costPer1kTokens": 0.015, - "costPer1kTokensOutput": 0.075, - "speedRating": 7, - "qualityRating": 10, - "capabilities": ["text_generation", "chat", "reasoning", "analysis"], - "tags": ["text", "chat", "reasoning", "analysis", "high_quality"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 - }, - "anthropic_callAiImage": { - "connector": "anthropic", - "function": "callAiImage", - "llmName": "claude-3-5-sonnet-20241022", - "contextLength": 200000, - "costPer1kTokens": 0.015, - "costPer1kTokensOutput": 0.075, - "speedRating": 7, - "qualityRating": 10, - "capabilities": ["image_analysis", "vision", "multimodal"], - "tags": ["image", "vision", "multimodal", "high_quality"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 - }, - - # Perplexity Models - "perplexity_callAiBasic": { - "connector": "perplexity", - "function": "callAiBasic", - "llmName": "llama-3.1-sonar-large-128k-online", - "contextLength": 128000, - "costPer1kTokens": 0.005, - "costPer1kTokensOutput": 0.005, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["text_generation", "chat", "reasoning", "web_search"], - "tags": ["text", "chat", "reasoning", "web_search", "cost_effective"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.005 - }, - "perplexity_callAiWithWebSearch": { - "connector": "perplexity", - "function": "callAiWithWebSearch", - "llmName": "sonar-pro", - "contextLength": 128000, - "costPer1kTokens": 0.01, - "costPer1kTokensOutput": 0.01, - "speedRating": 7, - "qualityRating": 9, - "capabilities": ["text_generation", "web_search", "research"], - "tags": ["text", "web_search", "research", "high_quality"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.01 + (bytesReceived / 4 / 1000) * 0.01 - }, - "perplexity_researchTopic": { - "connector": "perplexity", - "function": "researchTopic", - "llmName": "mistral-7b-instruct", - "contextLength": 32000, - "costPer1kTokens": 0.002, - "costPer1kTokensOutput": 0.002, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["web_search", "research", "information_gathering"], - "tags": ["web_search", "research", "information", "cost_effective"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 - }, - "perplexity_answerQuestion": { - "connector": "perplexity", - "function": "answerQuestion", - "llmName": "mistral-7b-instruct", - "contextLength": 32000, - "costPer1kTokens": 0.002, - "costPer1kTokensOutput": 0.002, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["web_search", "question_answering", "research"], - "tags": ["web_search", "qa", "research", "cost_effective"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 - }, - "perplexity_getCurrentNews": { - "connector": "perplexity", - "function": "getCurrentNews", - "llmName": "mistral-7b-instruct", - "contextLength": 32000, - "costPer1kTokens": 0.002, - "costPer1kTokensOutput": 0.002, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["web_search", "news", "current_events"], - "tags": ["web_search", "news", "current_events", "cost_effective"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 - }, - - # Tavily Web Models - "tavily_search": { - "connector": "tavily", - "function": "search", - "llmName": "tavily-search", - "contextLength": 0, - "costPer1kTokens": 0.0, # Not token-based - "costPer1kTokensOutput": 0.0, # Not token-based - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["web_search", "information_retrieval", "url_discovery"], - "tags": ["web", "search", "urls", "information"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, searchDepth="basic", numRequests=1: ( - # Basic search: 1 credit, Advanced: 2 credits - # Cost per credit: $0.008 - numRequests * (1 if searchDepth == "basic" else 2) * 0.008 - ) - }, - "tavily_extract": { - "connector": "tavily", - "function": "extract", - "llmName": "tavily-extract", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 8, - "capabilities": ["web_crawling", "content_extraction", "text_extraction"], - "tags": ["web", "extract", "content"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, extractionDepth="basic", numSuccessfulUrls=1: ( - # Basic: 1 credit per 5 URLs, Advanced: 2 credits per 5 URLs - # Only charged for successful extractions - (numSuccessfulUrls / 5) * (1 if extractionDepth == "basic" else 2) * 0.008 - ) - }, - "tavily_crawl": { - "connector": "tavily", - "function": "crawl", - "llmName": "tavily-crawl", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 8, - "capabilities": ["web_crawling", "content_extraction", "mapping"], - "tags": ["web", "crawl", "map", "extract"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, numPages=10, extractionDepth="basic", withInstructions=False, numSuccessfulExtractions=10: ( - # Crawl = Mapping + Extraction - # Mapping: 1 credit per 10 pages (2 if with instructions) - # Extraction: 1 credit per 5 successful extractions (2 if advanced) - ((numPages / 10) * (2 if withInstructions else 1) + - (numSuccessfulExtractions / 5) * (1 if extractionDepth == "basic" else 2)) * 0.008 - ) - }, - "tavily_scrape": { - "connector": "tavily", - "function": "scrape", - "llmName": "tavily-search-extract", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 8, - "capabilities": ["web_search", "web_crawling", "content_extraction", "information_retrieval"], - "tags": ["web", "search", "crawl", "extract", "content", "information"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, searchDepth="basic", numSuccessfulUrls=1, extractionDepth="basic": ( - # Combines search + extraction - # Search cost + extraction cost - (1 if searchDepth == "basic" else 2) + - (numSuccessfulUrls / 5) * (1 if extractionDepth == "basic" else 2) - ) * 0.008 - }, - - # Internal Models - "internal_extraction": { - "connector": "internal", - "function": "extract", - "llmName": "internal-extractor", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["document_extraction", "content_processing"], - "tags": ["internal", "extraction", "document_processing"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.001 + (bytesSent + bytesReceived) / (1024 * 1024) * 0.01 # $0.001 base + $0.01/MB - }, - "internal_generation": { - "connector": "internal", - "function": "generate", - "llmName": "internal-generator", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 7, - "qualityRating": 8, - "capabilities": ["document_generation", "content_creation"], - "tags": ["internal", "generation", "document_creation"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.002 + (bytesReceived / (1024 * 1024)) * 0.005 # $0.002 base + $0.005/MB output - }, - "internal_rendering": { - "connector": "internal", - "function": "render", - "llmName": "internal-renderer", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 9, - "capabilities": ["document_rendering", "format_conversion"], - "tags": ["internal", "rendering", "format_conversion"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.003 + (bytesReceived / (1024 * 1024)) * 0.008 # $0.003 base + $0.008/MB output - } -} - - -@dataclass(slots=True) -class AiObjects: - """Centralized AI interface: selects model and calls connector. Includes web functionality.""" - - openaiService: AiOpenai - anthropicService: AiAnthropic - perplexityService: AiPerplexity - tavilyService: ConnectorWeb - - def __post_init__(self) -> None: - if self.openaiService is None: - raise TypeError("openaiService must be provided") - if self.anthropicService is None: - raise TypeError("anthropicService must be provided") - if self.perplexityService is None: - raise TypeError("perplexityService must be provided") - if self.tavilyService is None: - raise TypeError("tavilyService must be provided") - - @classmethod - async def create(cls) -> "AiObjects": - """Create AiObjects instance with all connectors initialized.""" - openaiService = AiOpenai() - anthropicService = AiAnthropic() - perplexityService = AiPerplexity() - tavilyService = await ConnectorWeb.create() - - return cls( - openaiService=openaiService, - anthropicService=anthropicService, - perplexityService=perplexityService, - tavilyService=tavilyService - ) - - def _estimateCost(self, modelInfo: Dict[str, Any], contentSize: int) -> float: - estimatedTokens = contentSize / 4 - inputCost = (estimatedTokens / 1000) * modelInfo["costPer1kTokens"] - outputCost = (estimatedTokens / 1000) * modelInfo["costPer1kTokensOutput"] * 0.1 - return inputCost + outputCost - - - def _selectModel(self, prompt: str, context: str, options: AiCallOptions) -> str: - """Select the best model based on operation type, tags, and requirements.""" - totalSize = len(prompt.encode("utf-8")) + len(context.encode("utf-8")) - candidates: Dict[str, Dict[str, Any]] = {} - - # Determine required tags from operation type - requiredTags = options.requiredTags - if not requiredTags: - requiredTags = OPERATION_TAG_MAPPING.get(options.operationType, [ModelTags.TEXT, ModelTags.CHAT]) - - - # Override priority based on processing mode if not explicitly set - effectivePriority = options.priority - if options.priority == Priority.BALANCED: - effectivePriority = PROCESSING_MODE_PRIORITY_MAPPING.get(options.processingMode, Priority.BALANCED) - - logger.info(f"Model selection - Operation: {options.operationType}, Required tags: {requiredTags}, Priority: {effectivePriority}") - - for name, info in aiModels.items(): - logger.info(f"Checking model: {name}, tags: {info.get('tags', [])}, function: {info.get('function', 'unknown')}") - # Check context length - if info["contextLength"] > 0 and totalSize > info["contextLength"] * 0.8: - continue - - # Check cost constraints - if options.maxCost is not None: - if self._estimateCost(info, totalSize) > options.maxCost: - continue - - # Check required tags/capabilities - modelTags = info.get("tags", []) - if requiredTags and not all(tag in modelTags for tag in requiredTags): - logger.info(f" -> Skipping {name}: missing required tags. Has: {modelTags}, needs: {requiredTags}") - continue - else: - logger.info(f" -> {name} passed tag check") - - # Check processing mode requirements - if options.processingMode == ProcessingMode.DETAILED and ModelTags.FAST in modelTags: - # Skip fast models for detailed processing - continue - - candidates[name] = info - logger.info(f" -> {name} added to candidates") - - logger.info(f"Final candidates: {list(candidates.keys())}") - - if not candidates: - logger.info("No candidates found, using fallback") - # Fallback based on operation type - if options.operationType == OperationType.IMAGE_ANALYSIS: - logger.info("Using fallback: openai_callAiImage") - return "openai_callAiImage" - elif options.operationType == OperationType.IMAGE_GENERATION: - logger.info("Using fallback: openai_generateImage") - return "openai_generateImage" - elif options.operationType == OperationType.WEB_RESEARCH: - logger.info("Using fallback: perplexity_callAiWithWebSearch") - return "perplexity_callAiWithWebSearch" - else: - logger.info("Using fallback: openai_callAiBasic_gpt35") - return "openai_callAiBasic_gpt35" - - # Special handling for planning operations - use Claude for consistency - if options.operationType in [OperationType.GENERATE_PLAN, OperationType.ANALYSE_CONTENT]: - if "anthropic_callAiBasic" in candidates: - logger.info("Planning operation: Selected Claude (anthropic_callAiBasic) for highest quality") - return "anthropic_callAiBasic" - - # Fallback to GPT-4o if Claude not available - if "openai_callAiBasic" in candidates: - logger.info("Planning operation: Selected GPT-4o (openai_callAiBasic) as fallback") - return "openai_callAiBasic" - - # Select based on priority for other operations - if effectivePriority == Priority.SPEED: - selected = max(candidates, key=lambda k: candidates[k]["speedRating"]) - logger.info(f"Selected by SPEED: {selected}") - return selected - elif effectivePriority == Priority.QUALITY: - selected = max(candidates, key=lambda k: candidates[k]["qualityRating"]) - logger.info(f"Selected by QUALITY: {selected}") - return selected - elif effectivePriority == Priority.COST: - selected = min(candidates, key=lambda k: candidates[k]["costPer1kTokens"]) - logger.info(f"Selected by COST: {selected}") - return selected - else: # BALANCED - def balancedScore(name: str) -> float: - info = candidates[name] - return info["qualityRating"] * 0.4 + info["speedRating"] * 0.3 + (10 - info["costPer1kTokens"] * 1000) * 0.3 - - selected = max(candidates, key=balancedScore) - logger.info(f"Selected by BALANCED: {selected}") - return selected - - def _getFallbackModels(self, operationType: str) -> List[str]: - """Get ordered list of fallback models for a given operation type.""" - fallbackMappings = { - OperationType.GENERAL: [ - "openai_callAiBasic_gpt35", # Fast and reliable - "openai_callAiBasic", # High quality - "anthropic_callAiBasic", # Alternative high quality - "perplexity_callAiBasic" # Cost effective - ], - OperationType.IMAGE_ANALYSIS: [ - "openai_callAiImage", # Primary image analysis - "anthropic_callAiImage" # Alternative image analysis - ], - OperationType.IMAGE_GENERATION: [ - "openai_generateImage" # Only image generation model - ], - OperationType.WEB_RESEARCH: [ - "perplexity_callAiWithWebSearch", # Primary web research - "perplexity_callAiBasic", # Alternative with web search - "openai_callAiBasic" # Fallback to general model - ], - OperationType.GENERATE_PLAN: [ - "anthropic_callAiBasic", # Best for planning - "openai_callAiBasic", # High quality alternative - "openai_callAiBasic_gpt35" # Fast fallback - ], - OperationType.ANALYSE_CONTENT: [ - "anthropic_callAiBasic", # Best for analysis - "openai_callAiBasic", # High quality alternative - "openai_callAiBasic_gpt35" # Fast fallback - ] - } - - return fallbackMappings.get(operationType, fallbackMappings[OperationType.GENERAL]) - - def _connectorFor(self, modelName: str): - """Get the appropriate connector for the model.""" - connectorType = aiModels[modelName]["connector"] - if connectorType == "openai": - return self.openaiService - elif connectorType == "anthropic": - return self.anthropicService - elif connectorType == "perplexity": - return self.perplexityService - elif connectorType == "tavily": - return self.tavilyService - else: - raise ValueError(f"Unknown connector type: {connectorType}") - - async def call(self, request: AiCallRequest) -> AiCallResponse: - """Call AI model for text generation with fallback mechanism.""" - - prompt = request.prompt - context = request.context or "" - options = request.options - - # Calculate input bytes - inputBytes = len((prompt + context).encode("utf-8")) - - # Compress optionally (prompt/context) - simple truncation fallback kept here - def maybeTruncate(text: str, limit: int) -> str: - data = text.encode("utf-8") - if len(data) <= limit: - return text - return data[:limit].decode("utf-8", errors="ignore") + "... [truncated]" - - if options.compressPrompt and len(prompt.encode("utf-8")) > 2000: - prompt = maybeTruncate(prompt, 2000) - if options.compressContext and len(context.encode("utf-8")) > 70000: - context = maybeTruncate(context, 70000) - - # Derive generation parameters - temperature = getattr(options, "temperature", None) - if temperature is None: - temperature = 0.2 - maxTokens = getattr(options, "maxTokens", None) - # Don't set artificial limits - let the model use its full context length - # Our continuation system handles stopping early via prompt engineering - - - # Get fallback models for this operation type - fallbackModels = self._getFallbackModels(options.operationType) - - # Try primary model first, then fallbacks - lastError = None - for attempt, modelName in enumerate(fallbackModels): - try: - logger.info(f"Attempting AI call with model: {modelName} (attempt {attempt + 1}/{len(fallbackModels)})") - - # Replace placeholder in prompt for this specific model - context_length = aiModels[modelName].get("contextLength", 0) - if context_length > 0: - token_limit = str(context_length) - else: - token_limit = "16000" # Default for text generation - - # Create a copy of the prompt for this model call - modelPrompt = prompt - if "" in modelPrompt: - modelPrompt = modelPrompt.replace("", token_limit) - logger.debug(f"Replaced with {token_limit} for model {modelName}") - - # Update messages array with replaced content - messages = [] - if context: - messages.append({"role": "system", "content": f"Context from documents:\n{context}"}) - messages.append({"role": "user", "content": modelPrompt}) - - # Start timing - startTime = time.time() - - connector = self._connectorFor(modelName) - functionName = aiModels[modelName]["function"] - - # Call the appropriate function - if functionName == "callAiBasic": - if aiModels[modelName]["connector"] == "openai": - content = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) - elif aiModels[modelName]["connector"] == "perplexity": - content = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) - else: - response = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) - content = response["choices"][0]["message"]["content"] - elif functionName == "callAiWithWebSearch": - # Perplexity web search function - query = modelPrompt - if context: - query = f"Context: {context}\n\nQuery: {modelPrompt}" - content = await connector.callAiWithWebSearch(query) - elif functionName == "researchTopic": - # Perplexity research function - content = await connector.researchTopic(modelPrompt) - elif functionName == "answerQuestion": - # Perplexity question answering function - content = await connector.answerQuestion(modelPrompt, context) - elif functionName == "getCurrentNews": - # Perplexity news function - content = await connector.getCurrentNews(modelPrompt) - else: - raise ValueError(f"Function {functionName} not supported for text generation") - - # Calculate timing and output bytes - endTime = time.time() - processingTime = endTime - startTime - outputBytes = len(content.encode("utf-8")) - - # Calculate price - priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) - - logger.info(f"✅ AI call successful with model: {modelName}") - return AiCallResponse( - content=content, - modelName=modelName, - priceUsd=priceUsd, - processingTime=processingTime, - bytesSent=inputBytes, - bytesReceived=outputBytes, - errorCount=0 - ) - - except Exception as e: - lastError = e - # Enhanced error logging with more details - error_details = str(e) - if hasattr(e, 'detail'): - error_details = f"{error_details} (detail: {e.detail})" - if hasattr(e, 'status_code'): - error_details = f"{error_details} (status: {e.status_code})" - - logger.warning(f"❌ AI call failed with model {modelName}: {error_details}") - - # If this is not the last model, try the next one - if attempt < len(fallbackModels) - 1: - logger.info(f"🔄 Trying next fallback model...") - continue - else: - # All models failed - logger.error(f"💥 All {len(fallbackModels)} models failed for operation {options.operationType}") - break - - # All fallback attempts failed - return error response - last_error_details = str(lastError) - if hasattr(lastError, 'detail'): - last_error_details = f"{last_error_details} (detail: {lastError.detail})" - if hasattr(lastError, 'status_code'): - last_error_details = f"{last_error_details} (status: {lastError.status_code})" - - errorMsg = f"All AI models failed for operation {options.operationType}. Last error: {last_error_details}" - logger.error(errorMsg) - return AiCallResponse( - content=errorMsg, - modelName="error", - priceUsd=0.0, - processingTime=0.0, - bytesSent=inputBytes, - bytesReceived=0, - errorCount=1 - ) - - async def callImage(self, prompt: str, imageData: Union[str, bytes], mimeType: str = None, options: AiCallOptions = None) -> AiCallResponse: - """Call AI model for image analysis with fallback mechanism.""" - - if options is None: - options = AiCallOptions(operationType=OperationType.IMAGE_ANALYSIS) - - # Calculate input bytes (prompt + image data) - inputBytes = len(prompt.encode("utf-8")) + len(imageData) if isinstance(imageData, bytes) else len(prompt.encode("utf-8")) + len(str(imageData).encode("utf-8")) - - # Get fallback models for image analysis - fallbackModels = self._getFallbackModels(OperationType.IMAGE_ANALYSIS) - - # Try primary model first, then fallbacks - lastError = None - for attempt, modelName in enumerate(fallbackModels): - try: - logger.info(f"Attempting image analysis with model: {modelName} (attempt {attempt + 1}/{len(fallbackModels)})") - - # Start timing - startTime = time.time() - - connector = self._connectorFor(modelName) - functionName = aiModels[modelName]["function"] - - if functionName == "callAiImage": - content = await connector.callAiImage(prompt, imageData, mimeType) - - # Calculate timing and output bytes - endTime = time.time() - processingTime = endTime - startTime - outputBytes = len(content.encode("utf-8")) - - # Calculate price - priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) - - logger.info(f"✅ Image analysis successful with model: {modelName}") - return AiCallResponse( - content=content, - modelName=modelName, - priceUsd=priceUsd, - processingTime=processingTime, - bytesSent=inputBytes, - bytesReceived=outputBytes, - errorCount=0 - ) - else: - raise ValueError(f"Function {functionName} not supported for image analysis") - - except Exception as e: - lastError = e - logger.warning(f"❌ Image analysis failed with model {modelName}: {str(e)}") - - # If this is not the last model, try the next one - if attempt < len(fallbackModels) - 1: - logger.info(f"🔄 Trying next fallback model for image analysis...") - continue - else: - # All models failed - logger.error(f"💥 All {len(fallbackModels)} models failed for image analysis") - break - - # All fallback attempts failed - return error response - errorMsg = f"All AI models failed for image analysis. Last error: {str(lastError)}" - logger.error(errorMsg) - return AiCallResponse( - content=errorMsg, - modelName="error", - priceUsd=0.0, - processingTime=0.0, - bytesSent=inputBytes, - bytesReceived=0, - errorCount=1 - ) - - async def generateImage(self, prompt: str, size: str = "1024x1024", quality: str = "standard", style: str = "vivid", options: AiCallOptions = None) -> AiCallResponse: - """Generate an image using AI.""" - - if options is None: - options = AiCallOptions(operationType=OperationType.IMAGE_GENERATION) - - # Calculate input bytes - inputBytes = len(prompt.encode("utf-8")) - - # Select model for image generation - modelName = self._selectModel(prompt, "", options) - - try: - # Start timing - startTime = time.time() - - connector = self._connectorFor(modelName) - functionName = aiModels[modelName]["function"] - - if functionName == "generateImage": - result = await connector.generateImage(prompt, size, quality, style) - content = str(result) - elif functionName == "generateImageWithVariations": - results = await connector.generateImageWithVariations(prompt, 1, size, quality, style) - result = results[0] if results else {} - content = str(result) - elif functionName == "generateImageWithChat": - content = await connector.generateImageWithChat(prompt, size, quality, style) - else: - raise ValueError(f"Function {functionName} not supported for image generation") - - # Calculate timing and output bytes - endTime = time.time() - processingTime = endTime - startTime - outputBytes = len(content.encode("utf-8")) - - # Calculate price - priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) - - logger.info(f"✅ Image generation successful with model: {modelName}") - return AiCallResponse( - content=content, - modelName=modelName, - priceUsd=priceUsd, - processingTime=processingTime, - bytesSent=inputBytes, - bytesReceived=outputBytes, - errorCount=0 - ) - - except Exception as e: - logger.error(f"❌ Image generation failed with model {modelName}: {str(e)}") - return AiCallResponse( - content=f"Image generation failed: {str(e)}", - modelName=modelName, - priceUsd=0.0, - processingTime=0.0, - bytesSent=inputBytes, - bytesReceived=0, - errorCount=1 - ) - - # Web functionality methods - Simple interface to Tavily connector - async def search_websites(self, query: str, max_results: int = 5, **kwargs) -> List[WebSearchResultItem]: - """Search for websites using Tavily.""" - request = WebSearchRequest( - query=query, - max_results=max_results, - **kwargs - ) - result = await self.tavilyService.search(request) - - if result.success and result.documents: - return result.documents[0].documentData.results - return [] - - async def crawl_websites(self, urls: List[str], extract_depth: str = "advanced", format: str = "markdown") -> List[WebCrawlResultItem]: - """Crawl websites using Tavily.""" - from pydantic import HttpUrl - from urllib.parse import urlparse - - # Safely create HttpUrl objects with proper scheme handling - http_urls = [] - for url in urls: - try: - # Ensure URL has a scheme - parsed = urlparse(url) - if not parsed.scheme: - url = f"https://{url}" - - # Use HttpUrl with scheme parameter (this works for all URLs) - http_urls.append(HttpUrl(url, scheme="https")) - - except Exception as e: - logger.warning(f"Skipping invalid URL {url}: {e}") - continue - - if not http_urls: - return [] - - request = WebCrawlRequest( - urls=http_urls, - extract_depth=extract_depth, - format=format - ) - result = await self.tavilyService.crawl(request) - - if result.success and result.documents: - return result.documents[0].documentData.results - return [] - - async def extract_content(self, urls: List[str], extract_depth: str = "advanced", format: str = "markdown") -> Dict[str, str]: - """Extract content from URLs and return as dictionary.""" - crawl_results = await self.crawl_websites(urls, extract_depth, format) - return {str(result.url): result.content for result in crawl_results} - - # Core Web Tools - Clean interface for web operations - async def readPage(self, url: str, extract_depth: str = "advanced") -> Optional[str]: - """Read a single web page and return its content (HTML/Markdown).""" - logger.debug(f"Reading page: {url}") - try: - # URL encode the URL to handle spaces and special characters - from urllib.parse import quote, urlparse, urlunparse - parsed = urlparse(url) - encoded_url = urlunparse(( - parsed.scheme, - parsed.netloc, - parsed.path, - parsed.params, - parsed.query, - parsed.fragment - )) - - # Manually encode query parameters to handle spaces - if parsed.query: - encoded_query = quote(parsed.query, safe='=&') - encoded_url = urlunparse(( - parsed.scheme, - parsed.netloc, - parsed.path, - parsed.params, - encoded_query, - parsed.fragment - )) - - logger.debug(f"URL encoded: {url} -> {encoded_url}") - - content = await self.extract_content([encoded_url], extract_depth, "markdown") - result = content.get(encoded_url) - if result: - logger.debug(f"Successfully read page {encoded_url}: {len(result)} chars") - else: - logger.warning(f"No content returned for page {encoded_url}") - return result - except Exception as e: - logger.warning(f"Failed to read page {url}: {e}") - return None - - async def getUrlsFromPage(self, url: str, extract_depth: str = "advanced") -> List[str]: - """Get all URLs from a web page, with redundancies removed.""" - try: - content = await self.readPage(url, extract_depth) - if not content: - return [] - - links = self._extractLinksFromContent(content, url) - # Remove duplicates while preserving order - seen = set() - unique_links = [] - for link in links: - if link not in seen: - seen.add(link) - unique_links.append(link) - - logger.debug(f"Extracted {len(unique_links)} unique URLs from {url}") - return unique_links - - except Exception as e: - logger.warning(f"Failed to get URLs from page {url}: {e}") - return [] - - def filterUrlsOnlyPages(self, urls: List[str], max_per_domain: int = 10) -> List[str]: - """Filter URLs to get only links for pages to follow (no images, etc.).""" - from urllib.parse import urlparse - - def _isHtmlCandidate(url: str) -> bool: - lower = url.lower() - blocked = ('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.bmp', - '.mp4', '.mp3', '.avi', '.mov', '.mkv', - '.pdf', '.zip', '.rar', '.7z', '.tar', '.gz', - '.css', '.js', '.woff', '.woff2', '.ttf', '.eot') - return not lower.endswith(blocked) - - # Group by domain - domain_links = {} - for link in urls: - domain = urlparse(link).netloc - if domain not in domain_links: - domain_links[domain] = [] - domain_links[domain].append(link) - - # Filter and cap per domain - filtered_links = [] - for domain, domain_link_list in domain_links.items(): - seen = set() - domain_filtered = [] - - for link in domain_link_list: - if link in seen: - continue - if not _isHtmlCandidate(link): - continue - seen.add(link) - domain_filtered.append(link) - if len(domain_filtered) >= max_per_domain: - break - - filtered_links.extend(domain_filtered) - logger.debug(f"Domain {domain}: {len(domain_link_list)} -> {len(domain_filtered)} links") - - return filtered_links - - def _extractLinksFromContent(self, content: str, base_url: str) -> List[str]: - """Extract links from HTML/Markdown content.""" - try: - import re - from urllib.parse import urljoin, urlparse, quote, urlunparse - - def _cleanUrl(url: str) -> str: - """Clean and encode URL to remove spaces and invalid characters.""" - # Remove quotes and extra spaces - url = url.strip().strip('"\'') - - # If it's a relative URL, make it absolute first - if not url.startswith(('http://', 'https://')): - url = urljoin(base_url, url) - - # Parse and re-encode the URL properly - parsed = urlparse(url) - if parsed.query: - # Encode query parameters properly - encoded_query = quote(parsed.query, safe='=&') - url = urlunparse(( - parsed.scheme, - parsed.netloc, - parsed.path, - parsed.params, - encoded_query, - parsed.fragment - )) - - return url - - links = [] - - # Extract HTML links: format - html_link_pattern = r']+href=["\']([^"\']+)["\'][^>]*>' - html_links = re.findall(html_link_pattern, content, re.IGNORECASE) - - for url in html_links: - if url and not url.startswith('#') and not url.startswith('javascript:'): - try: - cleaned_url = _cleanUrl(url) - links.append(cleaned_url) - logger.debug(f"Extracted HTML link: {url} -> {cleaned_url}") - except Exception as e: - logger.debug(f"Failed to clean HTML link {url}: {e}") - - # Extract markdown links: [text](url) format - markdown_link_pattern = r'\[([^\]]+)\]\(([^)]+)\)' - markdown_links = re.findall(markdown_link_pattern, content) - - for text, url in markdown_links: - if url and not url.startswith('#'): - try: - cleaned_url = _cleanUrl(url) - # Only keep URLs from the same domain - if urlparse(cleaned_url).netloc == urlparse(base_url).netloc: - links.append(cleaned_url) - logger.debug(f"Extracted markdown link: {url} -> {cleaned_url}") - except Exception as e: - logger.debug(f"Failed to clean markdown link {url}: {e}") - - # Extract plain URLs in the text - url_pattern = r'https?://[^\s\)]+' - plain_urls = re.findall(url_pattern, content) - - for url in plain_urls: - try: - clean_url = url.rstrip('.,;!?') - cleaned_url = _cleanUrl(clean_url) - if urlparse(cleaned_url).netloc == urlparse(base_url).netloc: - if cleaned_url not in links: # Avoid duplicates - links.append(cleaned_url) - logger.debug(f"Extracted plain URL: {url} -> {cleaned_url}") - except Exception as e: - logger.debug(f"Failed to clean plain URL {url}: {e}") - - logger.debug(f"Total links extracted and cleaned: {len(links)}") - return links - - except Exception as e: - logger.warning(f"Failed to extract links from content: {e}") - return [] - - def _normalizeUrl(self, url: str) -> str: - """Normalize URL to handle variations that should be considered duplicates.""" - if not url: - return url - - # Remove trailing slashes and fragments - url = url.rstrip('/') - if '#' in url: - url = url.split('#')[0] - - # Handle common URL variations - url = url.replace('http://', 'https://') # Normalize protocol - - return url - - async def crawlRecursively(self, urls: List[str], max_depth: int, extract_depth: str = "advanced", max_per_domain: int = 10, global_processed_urls: Optional[set] = None) -> Dict[str, str]: - """ - Recursively crawl URLs up to specified depth. - - Args: - urls: List of starting URLs to crawl - max_depth: Maximum depth to crawl (1=main pages only, 2=main+sub-pages, etc.) - extract_depth: Tavily extract depth setting - max_per_domain: Maximum URLs per domain per level - global_processed_urls: Optional global set to track processed URLs across sessions - - Returns: - Dictionary mapping URL -> content for all crawled pages - """ - logger.info(f"Starting recursive crawl: {len(urls)} starting URLs, max_depth={max_depth}") - - # URL index to track all processed URLs (local + global) - processed_urls = set() - if global_processed_urls is not None: - # Use global index if provided, otherwise create local one - processed_urls = global_processed_urls - logger.info(f"Using global URL index with {len(processed_urls)} already processed URLs") - else: - logger.info("Using local URL index for this crawl session") - - all_content = {} - - # Current level URLs to process - current_level_urls = urls.copy() - - try: - for depth in range(1, max_depth + 1): - logger.info(f"=== DEPTH LEVEL {depth}/{max_depth} ===") - logger.info(f"Processing {len(current_level_urls)} URLs at depth {depth}") - - # URLs found at this level (for next iteration) - next_level_urls = [] - - for url in current_level_urls: - # Normalize URL for duplicate checking - normalized_url = self._normalizeUrl(url) - if normalized_url in processed_urls: - logger.debug(f"URL {url} (normalized: {normalized_url}) already processed, skipping") - continue - - try: - logger.info(f"Processing URL at depth {depth}: {url}") - logger.debug(f"Total processed URLs so far: {len(processed_urls)}") - - # Read page content - content = await self.readPage(url, extract_depth) - if content: - all_content[url] = content - processed_urls.add(normalized_url) - logger.info(f"✓ Successfully processed {url}: {len(content)} chars") - - # Get URLs from this page for next level - page_urls = await self.getUrlsFromPage(url, extract_depth) - logger.info(f"Found {len(page_urls)} URLs on {url}") - - # Filter URLs and add to next level - filtered_urls = self.filterUrlsOnlyPages(page_urls, max_per_domain) - logger.info(f"Filtered to {len(filtered_urls)} valid URLs") - - # Add new URLs to next level (avoiding already processed ones) - new_urls_count = 0 - for new_url in filtered_urls: - normalized_new_url = self._normalizeUrl(new_url) - if normalized_new_url not in processed_urls: - next_level_urls.append(new_url) - new_urls_count += 1 - else: - logger.debug(f"URL {new_url} (normalized: {normalized_new_url}) already processed, skipping") - - logger.info(f"Added {new_urls_count} new URLs to next level from {url}") - else: - logger.warning(f"✗ No content extracted from {url}") - processed_urls.add(normalized_url) # Mark as processed to avoid retry - - except Exception as e: - logger.warning(f"✗ Failed to process URL {url} at depth {depth}: {e}") - processed_urls.add(normalized_url) # Mark as processed to avoid retry - - # Prepare for next iteration - current_level_urls = next_level_urls - logger.info(f"Depth {depth} completed. Found {len(next_level_urls)} URLs for next level") - - # Stop if no more URLs to process - if not current_level_urls: - logger.info(f"No more URLs found at depth {depth}, stopping recursion") - break - - logger.info(f"Recursive crawl completed: {len(all_content)} total pages crawled") - logger.info(f"Total URLs processed (including skipped): {len(processed_urls)}") - logger.info(f"Unique URLs found: {len(all_content)}") - return all_content - - except asyncio.TimeoutError: - logger.warning(f"Crawling timed out, returning partial results: {len(all_content)} pages crawled so far") - return all_content - except Exception as e: - logger.error(f"Crawling failed with error: {e}, returning partial results: {len(all_content)} pages crawled so far") - return all_content - - async def webQuery(self, query: str, context: str = "", options: AiCallOptions = None) -> AiCallResponse: - """Use Perplexity AI to provide the best answers for web-related queries.""" - - if options is None: - options = AiCallOptions(operationType=OperationType.WEB_RESEARCH) - - # Calculate input bytes - inputBytes = len((query + context).encode("utf-8")) - - # Create a comprehensive prompt for web queries - webPrompt = f"""You are an expert web researcher and information analyst. Please provide a comprehensive and accurate answer to the following web-related query. - -Query: {query} - -{f"Additional Context: {context}" if context else ""} - -Please provide: -1. A clear, well-structured answer to the query -2. Key points and important details -3. Relevant insights and analysis -4. Any important considerations or caveats -5. Suggestions for further research if applicable - -Format your response in a clear, professional manner that would be helpful for someone researching this topic.""" - - try: - # Start timing - startTime = time.time() - - # Use Perplexity for web research with search capabilities - response = await self.perplexityService.callAiWithWebSearch(webPrompt) - - # Calculate timing and output bytes - endTime = time.time() - processingTime = endTime - startTime - outputBytes = len(response.encode("utf-8")) - - # Calculate price (use perplexity model pricing) - priceUsd = aiModels["perplexity_callAiWithWebSearch"]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) - - logger.info(f"✅ Web query successful with Perplexity") - return AiCallResponse( - content=response, - modelName="perplexity_callAiWithWebSearch", - priceUsd=priceUsd, - processingTime=processingTime, - bytesSent=inputBytes, - bytesReceived=outputBytes, - errorCount=0 - ) - except Exception as e: - logger.error(f"Perplexity web query failed: {str(e)}") - return AiCallResponse( - content=f"Web query failed: {str(e)}", - modelName="perplexity_callAiWithWebSearch", - priceUsd=0.0, - processingTime=0.0, - bytesSent=inputBytes, - bytesReceived=0, - errorCount=1 - ) - - # Utility methods - async def listAvailableModels(self, connectorType: str = None) -> List[Dict[str, Any]]: - """List available models, optionally filtered by connector type.""" - if connectorType: - return [info for name, info in aiModels.items() if info["connector"] == connectorType] - return list(aiModels.values()) - - async def getModelInfo(self, modelName: str) -> Dict[str, Any]: - """Get information about a specific model.""" - if modelName not in aiModels: - raise ValueError(f"Model {modelName} not found") - return aiModels[modelName] - - async def getModelsByCapability(self, capability: str) -> List[str]: - """Get model names that support a specific capability.""" - return [name for name, info in aiModels.items() if capability in info.get("capabilities", [])] - - async def getModelsByTag(self, tag: str) -> List[str]: - """Get model names that have a specific tag.""" - return [name for name, info in aiModels.items() if tag in info.get("tags", [])] - - async def selectRelevantWebsites(self, websites: List[str], userQuestion: str) -> Tuple[List[str], str]: - """Select most relevant websites using AI analysis. Returns (selected_websites, ai_response).""" - if len(websites) <= 1: - return websites, "Only one website available, no selection needed" - - try: - # Create website summaries for AI analysis - websiteSummaries = [] - for i, url in enumerate(websites, 1): - from urllib.parse import urlparse - domain = urlparse(url).netloc - summary = f"{i}. {url} (Domain: {domain})" - websiteSummaries.append(summary) - - selectionPrompt = f""" - Based on this user request: "{userQuestion}" - - I have {len(websites)} websites found. Please select the most relevant website(s) for this request. - - Available websites: - {chr(10).join(websiteSummaries)} - - Please respond with the website number(s) (1, 2, 3, etc.) that are most relevant. - Format: 1,3,5 (or just 1 for single selection) - """ - - # Use Perplexity to select the best websites - response = await self.webQuery(selectionPrompt) - - # Parse the selection - import re - numbers = re.findall(r'\d+', response) - if numbers: - selectedWebsites = [] - for num in numbers: - index = int(num) - 1 - if 0 <= index < len(websites): - selectedWebsites.append(websites[index]) - - if selectedWebsites: - logger.info(f"AI selected {len(selectedWebsites)} websites") - return selectedWebsites, response - - # Fallback to first website - logger.warning("AI selection failed, using first website") - return websites[:1], f"AI selection failed, fallback to first website. AI response: {response}" - - except Exception as e: - logger.error(f"Error in website selection: {str(e)}") - return websites[:1], f"Error in website selection: {str(e)}" - - async def analyzeContentWithChunking(self, allContent: Dict[str, str], userQuestion: str) -> str: - """Analyze content using AI with chunking for large content.""" - logger.info(f"Analyzing {len(allContent)} websites with AI") - - # Process content in chunks to avoid token limits - chunkSize = 50000 # 50k chars per chunk - allChunks = [] - - for url, content in allContent.items(): - filteredContent = self._filterContent(content) - if len(filteredContent) <= chunkSize: - allChunks.append((url, filteredContent)) - logger.info(f"Content from {url}: {len(filteredContent)} chars (single chunk)") - else: - # Split large content into chunks - chunkCount = (len(filteredContent) + chunkSize - 1) // chunkSize - logger.info(f"Content from {url}: {len(filteredContent)} chars (split into {chunkCount} chunks)") - for i in range(0, len(filteredContent), chunkSize): - chunk = filteredContent[i:i+chunkSize] - chunkNum = i//chunkSize + 1 - allChunks.append((f"{url} (part {chunkNum})", chunk)) - - logger.info(f"Processing {len(allChunks)} content chunks") - - # Analyze each chunk - chunkAnalyses = [] - for i, (url, chunk) in enumerate(allChunks, 1): - logger.info(f"Analyzing chunk {i}/{len(allChunks)}: {url}") - - try: - analysisPrompt = f""" - Analyze this web content and extract relevant information for: {userQuestion} - - Source: {url} - Content: {chunk} - - Please extract key information relevant to the query. - """ - - analysis = await self.webQuery(analysisPrompt) - chunkAnalyses.append(analysis) - logger.info(f"Chunk {i}/{len(allChunks)} analyzed successfully") - - except Exception as e: - logger.error(f"Chunk {i}/{len(allChunks)} error: {e}") - - # Combine all chunk analyses - if chunkAnalyses: - logger.info(f"Combining {len(chunkAnalyses)} chunk analyses") - combinedAnalysis = "\n\n".join(chunkAnalyses) - - # Final synthesis - try: - logger.info("Performing final synthesis of all analyses") - synthesisPrompt = f""" - Based on these partial analyses, provide a comprehensive answer to: {userQuestion} - - Partial analyses: - {combinedAnalysis} - - Please provide a clear, well-structured answer to the query. - """ - - finalAnalysis = await self.webQuery(synthesisPrompt) - logger.info("Final synthesis completed successfully") - return finalAnalysis - - except Exception as e: - logger.error(f"Synthesis error: {e}") - return combinedAnalysis - else: - logger.error("No content could be analyzed") - return "No content could be analyzed" - - def _filterContent(self, content: str) -> str: - """Filter out navigation, ads, and other nonsense content.""" - lines = content.split('\n') - filteredLines = [] - - for line in lines: - line = line.strip() - # Skip empty lines - if not line: - continue - # Skip navigation elements - if any(skip in line.lower() for skip in [ - 'toggle navigation', 'log in', 'sign up', 'cookies', 'privacy policy', - 'terms of service', 'subscribe', 'newsletter', 'follow us', 'share this', - 'advertisement', 'sponsored', 'banner', 'popup', 'modal' - ]): - continue - # Skip image references without context - if line.startswith('![Image') and '](' in line: - continue - # Skip pure links without context - if line.startswith('[') and line.endswith(')') and '---' in line: - continue - # Keep meaningful content - if len(line) > 10: # Skip very short lines - filteredLines.append(line) - - return '\n'.join(filteredLines) - diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py index 8aeedd4b..94013df0 100644 --- a/modules/interfaces/interfaceAiObjects.py +++ b/modules/interfaces/interfaceAiObjects.py @@ -6,19 +6,14 @@ import time logger = logging.getLogger(__name__) -# No more hardcoded imports - everything is discovered dynamically from modules.aicore.aicoreModelRegistry import model_registry from modules.aicore.aicoreModelSelector import model_selector from modules.datamodels.datamodelAi import ( + AiModel, AiCallOptions, AiCallRequest, AiCallResponse, - OperationType, - ProcessingMode, - Priority, - ModelTags, - OPERATION_TAG_MAPPING, - PROCESSING_MODE_PRIORITY_MAPPING + OperationTypeEnum, ) from modules.datamodels.datamodelWeb import ( WebResearchRequest, @@ -47,14 +42,14 @@ class AiObjects: logger.info("Auto-discovering AI connectors...") # Use the model registry's built-in discovery mechanism - discovered_connectors = model_registry.discoverConnectors() + discoveredConnectors = model_registry.discoverConnectors() # Register each discovered connector - for connector in discovered_connectors: + for connector in discoveredConnectors: model_registry.registerConnector(connector) logger.info(f"Registered connector: {connector.getConnectorType()}") - logger.info(f"Total connectors registered: {len(discovered_connectors)}") + logger.info(f"Total connectors registered: {len(discoveredConnectors)}") logger.info("All AI connectors registered with dynamic model registry") @classmethod @@ -68,25 +63,25 @@ class AiObjects: def _selectModel(self, prompt: str, context: str, options: AiCallOptions) -> str: """Select the best model using dynamic model selection system.""" # Get available models from the dynamic registry - available_models = model_registry.getAvailableModels() + availableModels = model_registry.getAvailableModels() - if not available_models: + if not availableModels: logger.error("No models available in the registry") raise ValueError("No AI models available") # Use the dynamic model selector - selected_model = model_selector.selectModel(prompt, context, options, available_models) + selectedModel = model_selector.selectModel(prompt, context, options, availableModels) - if not selected_model: + if not selectedModel: logger.error("No suitable model found for the given criteria") raise ValueError("No suitable AI model found") - logger.info(f"Selected model: {selected_model.name} ({selected_model.displayName})") - return selected_model.name + logger.info(f"Selected model: {selectedModel.name} ({selectedModel.displayName})") + return selectedModel.name async def call(self, request: AiCallRequest) -> AiCallResponse: - """Call AI model for text generation using dynamic model selection.""" + """Call AI model for text generation with fallback mechanism.""" prompt = request.prompt context = request.context or "" @@ -96,192 +91,247 @@ class AiObjects: inputBytes = len((prompt + context).encode("utf-8")) # Compress optionally (prompt/context) - simple truncation fallback kept here - def maybeTruncate(text: str, limit: int) -> str: + def _maybeTruncate(text: str, limit: int) -> str: data = text.encode("utf-8") if len(data) <= limit: return text return data[:limit].decode("utf-8", errors="ignore") + "... [truncated]" if options.compressPrompt and len(prompt.encode("utf-8")) > 2000: - prompt = maybeTruncate(prompt, 2000) + prompt = _maybeTruncate(prompt, 2000) if options.compressContext and len(context.encode("utf-8")) > 70000: - context = maybeTruncate(context, 70000) + context = _maybeTruncate(context, 70000) # Derive generation parameters temperature = getattr(options, "temperature", None) if temperature is None: temperature = 0.2 maxTokens = getattr(options, "maxTokens", None) - # Don't set artificial limits - let the model use its full context length - # Our continuation system handles stopping early via prompt engineering - - try: - # Select the best model using dynamic selection - modelName = self._selectModel(prompt, context, options) - selectedModel = model_registry.getModel(modelName) - - if not selectedModel: - raise ValueError(f"Selected model {modelName} not found in registry") - # Replace placeholder in prompt for this specific model - context_length = selectedModel.contextLength - if context_length > 0: - token_limit = str(context_length) - else: - token_limit = "16000" # Default for text generation - - # Create a copy of the prompt for this model call - modelPrompt = prompt - if "" in modelPrompt: - modelPrompt = modelPrompt.replace("", token_limit) - logger.debug(f"Replaced with {token_limit} for model {modelName}") - - # Update messages array with replaced content - messages = [] - if context: - messages.append({"role": "system", "content": f"Context from documents:\n{context}"}) - messages.append({"role": "user", "content": modelPrompt}) - - # Start timing - startTime = time.time() - - # Get the connector for this model - connector = model_registry.getConnectorForModel(modelName) - if not connector: - raise ValueError(f"No connector found for model {modelName}") - - # Call the model's function directly - if selectedModel.functionCall: - # Use the model's function call directly - if modelName.startswith("perplexity_callAiWithWebSearch"): - query = modelPrompt - if context: - query = f"Context: {context}\n\nQuery: {modelPrompt}" - content = await selectedModel.functionCall(query, temperature=temperature, maxTokens=maxTokens) - elif modelName.startswith("perplexity_researchTopic"): - content = await selectedModel.functionCall(modelPrompt) - elif modelName.startswith("perplexity_answerQuestion"): - content = await selectedModel.functionCall(modelPrompt, context) - elif modelName.startswith("perplexity_getCurrentNews"): - content = await selectedModel.functionCall(modelPrompt) - else: - # Standard callAiBasic - if selectedModel.connectorType == "anthropic": - response = await selectedModel.functionCall(messages, temperature=temperature, maxTokens=maxTokens) - content = response["choices"][0]["message"]["content"] - else: - content = await selectedModel.functionCall(messages, temperature=temperature, maxTokens=maxTokens) - else: - raise ValueError(f"Model {modelName} has no function call defined") - - # Calculate timing and output bytes - endTime = time.time() - processingTime = endTime - startTime - outputBytes = len(content.encode("utf-8")) - - # Calculate price using model's cost information - estimated_tokens = inputBytes / 4 - priceUsd = (estimated_tokens / 1000) * selectedModel.costPer1kTokens + (outputBytes / 4 / 1000) * selectedModel.costPer1kTokensOutput - - logger.info(f"✅ AI call successful with model: {modelName}") - logger.info(f" Processing time: {processingTime:.2f}s") - logger.info(f" Input: {inputBytes} bytes, Output: {outputBytes} bytes") - logger.info(f" Estimated cost: ${priceUsd:.4f}") - + # Get fallback models for this operation type + availableModels = model_registry.getAvailableModels() + fallbackModels = model_selector.getFallbackModels(prompt, context, options, availableModels) + + if not fallbackModels: + errorMsg = f"No suitable models found for operation {options.operationType}" + logger.error(errorMsg) return AiCallResponse( - success=True, - content=content, - model=modelName, - processingTime=processingTime, - priceUsd=priceUsd, - bytesSent=inputBytes, - bytesReceived=outputBytes, - errorCount=0 - ) - - except Exception as e: - logger.error(f"❌ AI call failed: {e}") - return AiCallResponse( - success=False, - content=f"AI call failed: {str(e)}", - model="none", - processingTime=0.0, + content=errorMsg, + modelName="error", priceUsd=0.0, + processingTime=0.0, bytesSent=inputBytes, bytesReceived=0, errorCount=1 ) + # Try each model in fallback sequence + lastError = None + for attempt, model in enumerate(fallbackModels): + try: + logger.info(f"Attempting AI call with model: {model.name} (attempt {attempt + 1}/{len(fallbackModels)})") + + # Call the model + response = await self._callWithModel(model, prompt, context, temperature, maxTokens, inputBytes) + + logger.info(f"✅ AI call successful with model: {model.name}") + return response + + except Exception as e: + lastError = e + logger.warning(f"❌ AI call failed with model {model.name}: {str(e)}") + + # If this is not the last model, try the next one + if attempt < len(fallbackModels) - 1: + logger.info(f"🔄 Trying next fallback model...") + continue + else: + # All models failed + logger.error(f"💥 All {len(fallbackModels)} models failed for operation {options.operationType}") + break + + # All fallback attempts failed - return error response + errorMsg = f"All AI models failed for operation {options.operationType}. Last error: {str(lastError)}" + logger.error(errorMsg) + return AiCallResponse( + content=errorMsg, + modelName="error", + priceUsd=0.0, + processingTime=0.0, + bytesSent=inputBytes, + bytesReceived=0, + errorCount=1 + ) + + async def _callWithModel(self, model: AiModel, prompt: str, context: str, temperature: float, maxTokens: int, inputBytes: int) -> AiCallResponse: + """Call a specific model and return the response.""" + # Replace placeholder in prompt for this specific model + contextLength = model.contextLength + if contextLength > 0: + tokenLimit = str(contextLength) + else: + tokenLimit = "16000" # Default for text generation + + # Create a copy of the prompt for this model call + modelPrompt = prompt + if "" in modelPrompt: + modelPrompt = modelPrompt.replace("", tokenLimit) + logger.debug(f"Replaced with {tokenLimit} for model {model.name}") + + # Update messages array with replaced content + messages = [] + if context: + messages.append({"role": "system", "content": f"Context from documents:\n{context}"}) + messages.append({"role": "user", "content": modelPrompt}) + + # Start timing + startTime = time.time() + + # Get the connector for this model + connector = model_registry.getConnectorForModel(model.name) + if not connector: + raise ValueError(f"No connector found for model {model.name}") + + # Call the model's function directly + if model.functionCall: + # Use the model's function call directly + if model.name.startswith("perplexity_callAiWithWebSearch"): + query = modelPrompt + if context: + query = f"Context: {context}\n\nQuery: {modelPrompt}" + content = await model.functionCall(query, temperature=temperature, maxTokens=maxTokens) + elif model.name.startswith("perplexity_researchTopic"): + content = await model.functionCall(modelPrompt) + elif model.name.startswith("perplexity_answerQuestion"): + content = await model.functionCall(modelPrompt, context) + elif model.name.startswith("perplexity_getCurrentNews"): + content = await model.functionCall(modelPrompt) + else: + # Standard callAiBasic + if model.connectorType == "anthropic": + response = await model.functionCall(messages, temperature=temperature, maxTokens=maxTokens) + content = response["choices"][0]["message"]["content"] + else: + content = await model.functionCall(messages, temperature=temperature, maxTokens=maxTokens) + else: + raise ValueError(f"Model {model.name} has no function call defined") + + # Calculate timing and output bytes + endTime = time.time() + processingTime = endTime - startTime + outputBytes = len(content.encode("utf-8")) + + # Calculate price using model's cost information + priceUsd = model.costPer1kTokensInput * (inputBytes / 4 / 1000) + model.costPer1kTokensOutput * (outputBytes / 4 / 1000) + + return AiCallResponse( + content=content, + modelName=model.name, + priceUsd=priceUsd, + processingTime=processingTime, + bytesSent=inputBytes, + bytesReceived=outputBytes, + errorCount=0 + ) + async def callImage(self, prompt: str, imageData: Union[str, bytes], mimeType: str = None, options: AiCallOptions = None) -> AiCallResponse: """Call AI model for image analysis with fallback mechanism.""" if options is None: - options = AiCallOptions(operationType=OperationType.IMAGE_ANALYSIS) + options = AiCallOptions(operationType=OperationTypeEnum.IMAGE_ANALYSE) # Calculate input bytes (prompt + image data) inputBytes = len(prompt.encode("utf-8")) + len(imageData) if isinstance(imageData, bytes) else len(prompt.encode("utf-8")) + len(str(imageData).encode("utf-8")) - try: - # Select the best model for image analysis - modelName = self._selectModel(prompt, "", options) - selectedModel = model_registry.getModel(modelName) - - if not selectedModel: - raise ValueError(f"Selected model {modelName} not found in registry") - - # Get the connector for this model - connector = model_registry.getConnectorForModel(modelName) - if not connector: - raise ValueError(f"No connector found for model {modelName}") - - # Start timing - startTime = time.time() - - # Call the model's function directly - if selectedModel.functionCall: - content = await selectedModel.functionCall(prompt, imageData, mimeType) - else: - raise ValueError(f"Model {modelName} has no function call defined") - - # Calculate timing and output bytes - endTime = time.time() - processingTime = endTime - startTime - outputBytes = len(content.encode("utf-8")) - - # Calculate price using model's cost information - estimated_tokens = inputBytes / 4 - priceUsd = (estimated_tokens / 1000) * selectedModel.costPer1kTokens + (outputBytes / 4 / 1000) * selectedModel.costPer1kTokensOutput - - logger.info(f"✅ Image analysis successful with model: {modelName}") + # Get fallback models for image analysis + availableModels = model_registry.getAvailableModels() + fallbackModels = model_selector.getFallbackModels(prompt, "", options, availableModels) + + if not fallbackModels: + errorMsg = f"No suitable models found for image analysis" + logger.error(errorMsg) return AiCallResponse( - success=True, - content=content, - model=modelName, - processingTime=processingTime, - priceUsd=priceUsd, - bytesSent=inputBytes, - bytesReceived=outputBytes, - errorCount=0 - ) - - except Exception as e: - logger.error(f"❌ Image analysis failed: {e}") - return AiCallResponse( - success=False, - content=f"Image analysis failed: {str(e)}", - model="none", - processingTime=0.0, + content=errorMsg, + modelName="error", priceUsd=0.0, + processingTime=0.0, bytesSent=inputBytes, bytesReceived=0, errorCount=1 ) + # Try each model in fallback sequence + lastError = None + for attempt, model in enumerate(fallbackModels): + try: + logger.info(f"Attempting image analysis with model: {model.name} (attempt {attempt + 1}/{len(fallbackModels)})") + + # Call the model + response = await self._callImageWithModel(model, prompt, imageData, mimeType, inputBytes) + + logger.info(f"✅ Image analysis successful with model: {model.name}") + return response + + except Exception as e: + lastError = e + logger.warning(f"❌ Image analysis failed with model {model.name}: {str(e)}") + + # If this is not the last model, try the next one + if attempt < len(fallbackModels) - 1: + logger.info(f"🔄 Trying next fallback model for image analysis...") + continue + else: + # All models failed + logger.error(f"💥 All {len(fallbackModels)} models failed for image analysis") + break + + # All fallback attempts failed - return error response + errorMsg = f"All AI models failed for image analysis. Last error: {str(lastError)}" + logger.error(errorMsg) + return AiCallResponse( + content=errorMsg, + modelName="error", + priceUsd=0.0, + processingTime=0.0, + bytesSent=inputBytes, + bytesReceived=0, + errorCount=1 + ) + + async def _callImageWithModel(self, model: AiModel, prompt: str, imageData: Union[str, bytes], mimeType: str, inputBytes: int) -> AiCallResponse: + """Call a specific model for image analysis and return the response.""" + # Start timing + startTime = time.time() + + # Call the model's function directly + if model.functionCall: + content = await model.functionCall(prompt, imageData, mimeType) + else: + raise ValueError(f"Model {model.name} has no function call defined") + + # Calculate timing and output bytes + endTime = time.time() + processingTime = endTime - startTime + outputBytes = len(content.encode("utf-8")) + + # Calculate price using model's cost information + priceUsd = model.costPer1kTokensInput * (inputBytes / 4 / 1000) + model.costPer1kTokensOutput * (outputBytes / 4 / 1000) + + return AiCallResponse( + content=content, + modelName=model.name, + priceUsd=priceUsd, + processingTime=processingTime, + bytesSent=inputBytes, + bytesReceived=outputBytes, + errorCount=0 + ) + async def generateImage(self, prompt: str, size: str = "1024x1024", quality: str = "standard", style: str = "vivid", options: AiCallOptions = None) -> AiCallResponse: """Generate an image using AI.""" if options is None: - options = AiCallOptions(operationType=OperationType.IMAGE_GENERATION) + options = AiCallOptions(operationType=OperationTypeEnum.IMAGE_GENERATE) # Calculate input bytes inputBytes = len(prompt.encode("utf-8")) @@ -315,8 +365,8 @@ class AiObjects: outputBytes = len(content.encode("utf-8")) # Calculate price using model's cost information - estimated_tokens = inputBytes / 4 - priceUsd = (estimated_tokens / 1000) * selectedModel.costPer1kTokens + (outputBytes / 4 / 1000) * selectedModel.costPer1kTokensOutput + estimatedTokens = inputBytes / 4 + priceUsd = (estimatedTokens / 1000) * selectedModel.costPer1kTokensInput + (outputBytes / 4 / 1000) * selectedModel.costPer1kTokensOutput logger.info(f"✅ Image generation successful with model: {modelName}") return AiCallResponse( @@ -343,30 +393,30 @@ class AiObjects: ) # Web functionality methods - Simple interface to Tavily connector - async def search_websites(self, query: str, max_results: int = 5, **kwargs) -> List[WebSearchResultItem]: + async def searchWebsites(self, query: str, maxResults: int = 5, **kwargs) -> List[WebSearchResultItem]: """Search for websites using Tavily.""" request = WebSearchRequest( query=query, - max_results=max_results, + max_results=maxResults, **kwargs ) # Get Tavily connector from registry - tavily_connector = model_registry.getConnectorForModel("tavily_search") - if not tavily_connector: + tavilyConnector = model_registry.getConnectorForModel("tavily_search") + if not tavilyConnector: raise ValueError("Tavily connector not available") - result = await tavily_connector.search(request) + result = await tavilyConnector.search(request) if result.success and result.documents: return result.documents[0].documentData.results return [] - async def crawl_websites(self, urls: List[str], extract_depth: str = "advanced", format: str = "markdown") -> List[WebCrawlResultItem]: + async def crawlWebsites(self, urls: List[str], extractDepth: str = "advanced", format: str = "markdown") -> List[WebCrawlResultItem]: """Crawl websites using Tavily.""" from pydantic import HttpUrl from urllib.parse import urlparse # Safely create HttpUrl objects with proper scheme handling - http_urls = [] + httpUrls = [] for url in urls: try: # Ensure URL has a scheme @@ -375,44 +425,44 @@ class AiObjects: url = f"https://{url}" # Use HttpUrl with scheme parameter (this works for all URLs) - http_urls.append(HttpUrl(url, scheme="https")) + httpUrls.append(HttpUrl(url, scheme="https")) except Exception as e: logger.warning(f"Skipping invalid URL {url}: {e}") continue - if not http_urls: + if not httpUrls: return [] request = WebCrawlRequest( - urls=http_urls, - extract_depth=extract_depth, + urls=httpUrls, + extract_depth=extractDepth, format=format ) # Get Tavily connector from registry - tavily_connector = model_registry.getConnectorForModel("tavily_crawl") - if not tavily_connector: + tavilyConnector = model_registry.getConnectorForModel("tavily_crawl") + if not tavilyConnector: raise ValueError("Tavily connector not available") - result = await tavily_connector.crawl(request) + result = await tavilyConnector.crawl(request) if result.success and result.documents: return result.documents[0].documentData.results return [] - async def extract_content(self, urls: List[str], extract_depth: str = "advanced", format: str = "markdown") -> Dict[str, str]: + async def extractContent(self, urls: List[str], extractDepth: str = "advanced", format: str = "markdown") -> Dict[str, str]: """Extract content from URLs and return as dictionary.""" - crawl_results = await self.crawl_websites(urls, extract_depth, format) - return {str(result.url): result.content for result in crawl_results} + crawlResults = await self.crawlWebsites(urls, extractDepth, format) + return {str(result.url): result.content for result in crawlResults} # Core Web Tools - Clean interface for web operations - async def readPage(self, url: str, extract_depth: str = "advanced") -> Optional[str]: + async def readPage(self, url: str, extractDepth: str = "advanced") -> Optional[str]: """Read a single web page and return its content (HTML/Markdown).""" logger.debug(f"Reading page: {url}") try: # URL encode the URL to handle spaces and special characters from urllib.parse import quote, urlparse, urlunparse parsed = urlparse(url) - encoded_url = urlunparse(( + encodedUrl = urlunparse(( parsed.scheme, parsed.netloc, parsed.path, @@ -423,53 +473,53 @@ class AiObjects: # Manually encode query parameters to handle spaces if parsed.query: - encoded_query = quote(parsed.query, safe='=&') - encoded_url = urlunparse(( + encodedQuery = quote(parsed.query, safe='=&') + encodedUrl = urlunparse(( parsed.scheme, parsed.netloc, parsed.path, parsed.params, - encoded_query, + encodedQuery, parsed.fragment )) - logger.debug(f"URL encoded: {url} -> {encoded_url}") + logger.debug(f"URL encoded: {url} -> {encodedUrl}") - content = await self.extract_content([encoded_url], extract_depth, "markdown") - result = content.get(encoded_url) + content = await self.extractContent([encodedUrl], extractDepth, "markdown") + result = content.get(encodedUrl) if result: - logger.debug(f"Successfully read page {encoded_url}: {len(result)} chars") + logger.debug(f"Successfully read page {encodedUrl}: {len(result)} chars") else: - logger.warning(f"No content returned for page {encoded_url}") + logger.warning(f"No content returned for page {encodedUrl}") return result except Exception as e: logger.warning(f"Failed to read page {url}: {e}") return None - async def getUrlsFromPage(self, url: str, extract_depth: str = "advanced") -> List[str]: + async def getUrlsFromPage(self, url: str, extractDepth: str = "advanced") -> List[str]: """Get all URLs from a web page, with redundancies removed.""" try: - content = await self.readPage(url, extract_depth) + content = await self.readPage(url, extractDepth) if not content: return [] links = self._extractLinksFromContent(content, url) # Remove duplicates while preserving order seen = set() - unique_links = [] + uniqueLinks = [] for link in links: if link not in seen: seen.add(link) - unique_links.append(link) + uniqueLinks.append(link) - logger.debug(f"Extracted {len(unique_links)} unique URLs from {url}") - return unique_links + logger.debug(f"Extracted {len(uniqueLinks)} unique URLs from {url}") + return uniqueLinks except Exception as e: logger.warning(f"Failed to get URLs from page {url}: {e}") return [] - def filterUrlsOnlyPages(self, urls: List[str], max_per_domain: int = 10) -> List[str]: + def filterUrlsOnlyPages(self, urls: List[str], maxPerDomain: int = 10) -> List[str]: """Filter URLs to get only links for pages to follow (no images, etc.).""" from urllib.parse import urlparse @@ -482,35 +532,35 @@ class AiObjects: return not lower.endswith(blocked) # Group by domain - domain_links = {} + domainLinks = {} for link in urls: domain = urlparse(link).netloc - if domain not in domain_links: - domain_links[domain] = [] - domain_links[domain].append(link) + if domain not in domainLinks: + domainLinks[domain] = [] + domainLinks[domain].append(link) # Filter and cap per domain - filtered_links = [] - for domain, domain_link_list in domain_links.items(): + filteredLinks = [] + for domain, domainLinkList in domainLinks.items(): seen = set() - domain_filtered = [] + domainFiltered = [] - for link in domain_link_list: + for link in domainLinkList: if link in seen: continue if not _isHtmlCandidate(link): continue seen.add(link) - domain_filtered.append(link) - if len(domain_filtered) >= max_per_domain: + domainFiltered.append(link) + if len(domainFiltered) >= maxPerDomain: break - filtered_links.extend(domain_filtered) - logger.debug(f"Domain {domain}: {len(domain_link_list)} -> {len(domain_filtered)} links") + filteredLinks.extend(domainFiltered) + logger.debug(f"Domain {domain}: {len(domainLinkList)} -> {len(domainFiltered)} links") - return filtered_links + return filteredLinks - def _extractLinksFromContent(self, content: str, base_url: str) -> List[str]: + def _extractLinksFromContent(self, content: str, baseUrl: str) -> List[str]: """Extract links from HTML/Markdown content.""" try: import re @@ -523,19 +573,19 @@ class AiObjects: # If it's a relative URL, make it absolute first if not url.startswith(('http://', 'https://')): - url = urljoin(base_url, url) + url = urljoin(baseUrl, url) # Parse and re-encode the URL properly parsed = urlparse(url) if parsed.query: # Encode query parameters properly - encoded_query = quote(parsed.query, safe='=&') + encodedQuery = quote(parsed.query, safe='=&') url = urlunparse(( parsed.scheme, parsed.netloc, parsed.path, parsed.params, - encoded_query, + encodedQuery, parsed.fragment )) @@ -544,45 +594,45 @@ class AiObjects: links = [] # Extract HTML links: format - html_link_pattern = r']+href=["\']([^"\']+)["\'][^>]*>' - html_links = re.findall(html_link_pattern, content, re.IGNORECASE) + htmlLinkPattern = r']+href=["\']([^"\']+)["\'][^>]*>' + htmlLinks = re.findall(htmlLinkPattern, content, re.IGNORECASE) - for url in html_links: + for url in htmlLinks: if url and not url.startswith('#') and not url.startswith('javascript:'): try: - cleaned_url = _cleanUrl(url) - links.append(cleaned_url) - logger.debug(f"Extracted HTML link: {url} -> {cleaned_url}") + cleanedUrl = _cleanUrl(url) + links.append(cleanedUrl) + logger.debug(f"Extracted HTML link: {url} -> {cleanedUrl}") except Exception as e: logger.debug(f"Failed to clean HTML link {url}: {e}") # Extract markdown links: [text](url) format - markdown_link_pattern = r'\[([^\]]+)\]\(([^)]+)\)' - markdown_links = re.findall(markdown_link_pattern, content) + markdownLinkPattern = r'\[([^\]]+)\]\(([^)]+)\)' + markdownLinks = re.findall(markdownLinkPattern, content) - for text, url in markdown_links: + for text, url in markdownLinks: if url and not url.startswith('#'): try: - cleaned_url = _cleanUrl(url) + cleanedUrl = _cleanUrl(url) # Only keep URLs from the same domain - if urlparse(cleaned_url).netloc == urlparse(base_url).netloc: - links.append(cleaned_url) - logger.debug(f"Extracted markdown link: {url} -> {cleaned_url}") + if urlparse(cleanedUrl).netloc == urlparse(baseUrl).netloc: + links.append(cleanedUrl) + logger.debug(f"Extracted markdown link: {url} -> {cleanedUrl}") except Exception as e: logger.debug(f"Failed to clean markdown link {url}: {e}") # Extract plain URLs in the text - url_pattern = r'https?://[^\s\)]+' - plain_urls = re.findall(url_pattern, content) + urlPattern = r'https?://[^\s\)]+' + plainUrls = re.findall(urlPattern, content) - for url in plain_urls: + for url in plainUrls: try: - clean_url = url.rstrip('.,;!?') - cleaned_url = _cleanUrl(clean_url) - if urlparse(cleaned_url).netloc == urlparse(base_url).netloc: - if cleaned_url not in links: # Avoid duplicates - links.append(cleaned_url) - logger.debug(f"Extracted plain URL: {url} -> {cleaned_url}") + cleanUrl = url.rstrip('.,;!?') + cleanedUrl = _cleanUrl(cleanUrl) + if urlparse(cleanedUrl).netloc == urlparse(baseUrl).netloc: + if cleanedUrl not in links: # Avoid duplicates + links.append(cleanedUrl) + logger.debug(f"Extracted plain URL: {url} -> {cleanedUrl}") except Exception as e: logger.debug(f"Failed to clean plain URL {url}: {e}") @@ -716,7 +766,7 @@ class AiObjects: """Use Perplexity AI to provide the best answers for web-related queries.""" if options is None: - options = AiCallOptions(operationType=OperationType.WEB_RESEARCH) + options = AiCallOptions(operationType=OperationTypeEnum.WEB_RESEARCH) # Calculate input bytes inputBytes = len((query + context).encode("utf-8")) @@ -756,7 +806,7 @@ Format your response in a clear, professional manner that would be helpful for s perplexity_model = model_registry.getModel("perplexity_callAiWithWebSearch") if perplexity_model: estimated_tokens = inputBytes / 4 - priceUsd = (estimated_tokens / 1000) * perplexity_model.costPer1kTokens + (outputBytes / 4 / 1000) * perplexity_model.costPer1kTokensOutput + priceUsd = (estimated_tokens / 1000) * perplexity_model.costPer1kTokensInput + (outputBytes / 4 / 1000) * perplexity_model.costPer1kTokensOutput else: priceUsd = 0.0 diff --git a/modules/services/serviceAi/mainServiceAi.py b/modules/services/serviceAi/mainServiceAi.py index 511fb311..7d77d9e1 100644 --- a/modules/services/serviceAi/mainServiceAi.py +++ b/modules/services/serviceAi/mainServiceAi.py @@ -1,11 +1,8 @@ import logging -import re from typing import Dict, Any, List, Optional, Tuple, Union -from modules.datamodels.datamodelChat import PromptPlaceholder - -from modules.datamodels.datamodelChat import ChatDocument +from modules.datamodels.datamodelChat import PromptPlaceholder, ChatDocument from modules.services.serviceExtraction.mainServiceExtraction import ExtractionService -from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, ModelCapabilities, OperationType, Priority +from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum from modules.datamodels.datamodelExtraction import ChunkResult, ContentExtracted from modules.datamodels.datamodelWeb import ( WebResearchRequest, @@ -19,6 +16,7 @@ from modules.services.serviceAi.subCoreAi import SubCoreAi from modules.services.serviceAi.subDocumentProcessing import SubDocumentProcessing from modules.services.serviceAi.subWebResearch import SubWebResearch from modules.services.serviceAi.subDocumentGeneration import SubDocumentGeneration +from modules.services.serviceAi.subSharedAiUtils import sanitizePromptContent logger = logging.getLogger(__name__) @@ -170,68 +168,3 @@ class AiService: return await self.coreAi.callAiDocuments(prompt, documents, options, outputFormat, title, "json") - def sanitizePromptContent(self, content: str, contentType: str = "text") -> str: - """ - Centralized prompt content sanitization to prevent injection attacks and ensure safe presentation. - - This is the single source of truth for all prompt sanitization across the system. - Replaces all scattered sanitization functions with a unified approach. - - Args: - content: The content to sanitize - contentType: Type of content ("text", "userinput", "json", "document") - - Returns: - Safely sanitized content ready for AI prompt insertion - """ - if not content: - return "" - - try: - # Convert to string if not already - content_str = str(content) - - # Remove null bytes and control characters (except newlines and tabs) - sanitized = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', content_str) - - # Handle different content types with appropriate sanitization - if contentType == "userinput": - # Extra security for user-controlled content - # Escape curly braces to prevent placeholder injection - sanitized = sanitized.replace('{', '{{').replace('}', '}}') - # Escape quotes and wrap in single quotes - sanitized = sanitized.replace('"', '\\"').replace("'", "\\'") - return f"'{sanitized}'" - - elif contentType == "json": - # For JSON content, escape quotes and backslashes - sanitized = sanitized.replace('\\', '\\\\') - sanitized = sanitized.replace('"', '\\"') - sanitized = sanitized.replace('\n', '\\n') - sanitized = sanitized.replace('\r', '\\r') - sanitized = sanitized.replace('\t', '\\t') - - elif contentType == "document": - # For document content, escape special characters - sanitized = sanitized.replace('\\', '\\\\') - sanitized = sanitized.replace('"', '\\"') - sanitized = sanitized.replace("'", "\\'") - sanitized = sanitized.replace('\n', '\\n') - sanitized = sanitized.replace('\r', '\\r') - sanitized = sanitized.replace('\t', '\\t') - - else: # contentType == "text" or default - # Basic text sanitization - sanitized = sanitized.replace('\\', '\\\\') - sanitized = sanitized.replace('"', '\\"') - sanitized = sanitized.replace("'", "\\'") - sanitized = sanitized.replace('\n', '\\n') - sanitized = sanitized.replace('\r', '\\r') - sanitized = sanitized.replace('\t', '\\t') - - return sanitized - - except Exception as e: - logger.error(f"Error sanitizing prompt content: {str(e)}") - # Return a safe fallback - return "[ERROR: Content could not be safely sanitized]" diff --git a/modules/services/serviceAi/subCoreAi.py b/modules/services/serviceAi/subCoreAi.py index 07caa972..bb735c5d 100644 --- a/modules/services/serviceAi/subCoreAi.py +++ b/modules/services/serviceAi/subCoreAi.py @@ -2,7 +2,13 @@ import json import logging from typing import Dict, Any, List, Optional, Tuple, Union from modules.datamodels.datamodelChat import PromptPlaceholder, ChatDocument -from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, ModelCapabilities, OperationType, Priority +from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum +from modules.services.serviceAi.subSharedAiUtils import ( + buildPromptWithPlaceholders, + extractTextFromContentParts, + reduceText, + determineCallType +) logger = logging.getLogger(__name__) @@ -289,23 +295,6 @@ CRITICAL REQUIREMENTS: logger.error(f"Error merging JSON content: {str(e)}") return accumulatedContent[0] # Return first response on error - - 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 - async def _buildGenerationPrompt( self, prompt: str, @@ -359,12 +348,12 @@ CRITICAL REQUIREMENTS: # Build full prompt with placeholders if placeholders: placeholders_dict = {p.label: p.content for p in placeholders} - full_prompt = self._buildPromptWithPlaceholders(prompt, placeholders_dict) + full_prompt = buildPromptWithPlaceholders(prompt, placeholders_dict) else: full_prompt = prompt # Use shared core function with planning-specific debug prefix - return await self._callAiWithLooping(full_prompt, options, "planning", loopInstructionFormat=loopInstructionFormat) + return await self._callAiWithLooping(full_prompt, options, "plan", loopInstructionFormat=loopInstructionFormat) # Document Generation AI Call async def callAiDocuments( @@ -485,12 +474,12 @@ CRITICAL REQUIREMENTS: self.services.utils.debugLogToFile(f"readImage called with prompt, imageData type: {type(imageData)}, length: {len(imageData) if imageData else 0}, mimeType: {mimeType}", "AI_SERVICE") logger.info(f"readImage called with prompt, imageData type: {type(imageData)}, length: {len(imageData) if imageData else 0}, mimeType: {mimeType}") - # Always use IMAGE_ANALYSIS operation type for image processing + # Always use IMAGE_ANALYSE operation type for image processing if options is None: - options = AiCallOptions(operationType=OperationType.IMAGE_ANALYSIS) + options = AiCallOptions(operationType=OperationTypeEnum.IMAGE_ANALYSE) else: # Override the operation type to ensure image analysis - options.operationType = OperationType.IMAGE_ANALYSIS + options.operationType = OperationTypeEnum.IMAGE_ANALYSE self.services.utils.debugLogToFile(f"Calling aiObjects.callImage with operationType: {options.operationType}", "AI_SERVICE") logger.info(f"Calling aiObjects.callImage with operationType: {options.operationType}") @@ -559,20 +548,6 @@ CRITICAL REQUIREMENTS: logger.error(f"Error in AI image generation: {str(e)}") return {"success": False, "error": str(e)} - 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" -> planning - All other cases -> text - """ - has_documents = documents is not None and len(documents) > 0 - is_planning_operation = operation_type == OperationType.GENERATE_PLAN - - if not has_documents and is_planning_operation: - return "planning" - else: - return "text" def _getModelCapabilitiesForContent(self, prompt: str, documents: Optional[List[ChatDocument]], options: AiCallOptions) -> Dict[str, int]: @@ -606,11 +581,11 @@ CRITICAL REQUIREMENTS: # Check if model supports the operation type capabilities = model_info.get("capabilities", []) - if options.operationType == OperationType.IMAGE_ANALYSIS and "image_analysis" not in capabilities: + if options.operationType == OperationTypeEnum.IMAGE_ANALYSE and "imageAnalyse" not in capabilities: continue - elif options.operationType == OperationType.IMAGE_GENERATION and "image_generation" not in capabilities: + elif options.operationType == OperationTypeEnum.IMAGE_GENERATE and "imageGenerate" not in capabilities: continue - elif options.operationType == OperationType.WEB_RESEARCH and "web_search" not in capabilities: + elif options.operationType == OperationTypeEnum.WEB_RESEARCH and "web_search" not in capabilities: continue elif "text_generation" not in capabilities: continue @@ -649,68 +624,4 @@ CRITICAL REQUIREMENTS: "imageChunkSize": image_chunk_size } - 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 _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 _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]" diff --git a/modules/services/serviceAi/subDocumentGeneration.py b/modules/services/serviceAi/subDocumentGeneration.py index d40f2439..6ec7b932 100644 --- a/modules/services/serviceAi/subDocumentGeneration.py +++ b/modules/services/serviceAi/subDocumentGeneration.py @@ -5,7 +5,7 @@ import time from datetime import datetime, UTC from typing import Dict, Any, List, Optional, Tuple, Union from modules.datamodels.datamodelChat import ChatDocument -from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType +from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum logger = logging.getLogger(__name__) @@ -335,9 +335,9 @@ class SubDocumentGeneration: ) # Prepare the AI call - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum requestOptions = AiCallOptions() - requestOptions.operationType = OperationType.GENERAL + requestOptions.operationType = OperationTypeEnum.GENERAL # Create context with the extracted JSON content context = f"Extracted JSON content:\n{json.dumps(docData, indent=2)}" @@ -477,9 +477,9 @@ Consider: Return only the JSON response. """ - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL request = AiCallRequest(prompt=analysis_prompt, context="", options=request_options) response = await ai_service.aiObjects.call(request) diff --git a/modules/services/serviceAi/subDocumentProcessing.py b/modules/services/serviceAi/subDocumentProcessing.py index 81d355e4..48fda85e 100644 --- a/modules/services/serviceAi/subDocumentProcessing.py +++ b/modules/services/serviceAi/subDocumentProcessing.py @@ -4,7 +4,7 @@ import re import time from typing import Dict, Any, List, Optional, Tuple, Union from modules.datamodels.datamodelChat import ChatDocument -from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, ModelCapabilities, OperationType, Priority +from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum from modules.datamodels.datamodelExtraction import ChunkResult, ContentExtracted from modules.services.serviceExtraction.mainServiceExtraction import ExtractionService @@ -84,7 +84,7 @@ class SubDocumentProcessing: "imageQuality": 85, "mergeStrategy": { "useIntelligentMerging": True, # Enable intelligent token-aware merging - "modelCapabilities": model_capabilities, + "capabilities": model_capabilities, "prompt": prompt, "groupBy": "typeGroup", "orderBy": "id", @@ -145,7 +145,7 @@ class SubDocumentProcessing: "imageQuality": 85, "mergeStrategy": { "useIntelligentMerging": True, # Enable intelligent token-aware merging - "modelCapabilities": model_capabilities, + "capabilities": model_capabilities, "prompt": prompt, "groupBy": "typeGroup", "orderBy": "id", @@ -240,7 +240,7 @@ class SubDocumentProcessing: "imageQuality": 85, "mergeStrategy": { "useIntelligentMerging": True, # Enable intelligent token-aware merging - "modelCapabilities": model_capabilities, + "capabilities": model_capabilities, "prompt": custom_prompt, # Use the custom prompt "groupBy": "typeGroup", "orderBy": "id", @@ -666,7 +666,7 @@ CONTINUATION INSTRUCTIONS: elif part.mimeType and part.data and len(part.data.strip()) > 0: # Process any document container as text content request_options = options if options is not None else AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL self.services.utils.debugLogToFile(f"EXTRACTION CONTAINER CHUNK {chunk_index}: Processing {part.mimeType} container as text with generate_json={generate_json}", "AI_SERVICE") logger.info(f"Chunk {chunk_index}: Processing {part.mimeType} container as text with generate_json={generate_json}") @@ -755,7 +755,7 @@ CONTINUATION INSTRUCTIONS: # Ensure options is not None and set correct operation type for text request_options = options if options is not None else AiCallOptions() # FIXED: Set operation type to general for text processing - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL self.services.utils.debugLogToFile(f"EXTRACTION CHUNK {chunk_index}: Calling aiObjects.call with operationType={request_options.operationType}, generate_json={generate_json}", "AI_SERVICE") logger.info(f"Chunk {chunk_index}: Calling aiObjects.call with operationType={request_options.operationType}, generate_json={generate_json}") @@ -1222,11 +1222,11 @@ CONTINUATION INSTRUCTIONS: # Check if model supports the operation type capabilities = model_info.get("capabilities", []) - if options.operationType == OperationType.IMAGE_ANALYSIS and "image_analysis" not in capabilities: + if options.operationType == OperationTypeEnum.IMAGE_ANALYSE and "imageAnalyse" not in capabilities: continue - elif options.operationType == OperationType.IMAGE_GENERATION and "image_generation" not in capabilities: + elif options.operationType == OperationTypeEnum.IMAGE_GENERATE and "imageGenerate" not in capabilities: continue - elif options.operationType == OperationType.WEB_RESEARCH and "web_search" not in capabilities: + elif options.operationType == OperationTypeEnum.WEB_RESEARCH and "web_search" not in capabilities: continue elif "text_generation" not in capabilities: continue diff --git a/modules/services/serviceAi/subSharedAiUtils.py b/modules/services/serviceAi/subSharedAiUtils.py new file mode 100644 index 00000000..198f8aee --- /dev/null +++ b/modules/services/serviceAi/subSharedAiUtils.py @@ -0,0 +1,164 @@ +""" +Shared utilities for AI services to eliminate code duplication. + +This module contains common functions used across multiple AI service modules +to maintain DRY principles and ensure consistency. +""" + +import re +import logging +from typing import Dict, Any, List, Optional, Union +from modules.datamodels.datamodelChat import PromptPlaceholder + +logger = logging.getLogger(__name__) + + +def buildPromptWithPlaceholders(prompt: str, placeholders: Optional[Dict[str, str]]) -> str: + """ + Build full prompt by replacing placeholders with their content. + Uses the new {{KEY:placeholder}} format. + + Args: + prompt: The base prompt template + placeholders: Dictionary of placeholder key-value pairs + + Returns: + Prompt with placeholders replaced + """ + 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 sanitizePromptContent(content: str, contentType: str = "text") -> str: + """ + Centralized prompt content sanitization to prevent injection attacks and ensure safe presentation. + + This is the single source of truth for all prompt sanitization across the system. + Replaces all scattered sanitization functions with a unified approach. + + Args: + content: The content to sanitize + contentType: Type of content ("text", "userinput", "json", "document") + + Returns: + Safely sanitized content ready for AI prompt insertion + """ + if not content: + return "" + + try: + # Convert to string if not already + content_str = str(content) + + # Remove null bytes and control characters (except newlines and tabs) + sanitized = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', content_str) + + # Handle different content types with appropriate sanitization + if contentType == "userinput": + # Extra security for user-controlled content + # Escape curly braces to prevent placeholder injection + sanitized = sanitized.replace('{', '{{').replace('}', '}}') + # Escape quotes and wrap in single quotes + sanitized = sanitized.replace('"', '\\"').replace("'", "\\'") + return f"'{sanitized}'" + + elif contentType == "json": + # For JSON content, escape quotes and backslashes + sanitized = sanitized.replace('\\', '\\\\') + sanitized = sanitized.replace('"', '\\"') + sanitized = sanitized.replace('\n', '\\n') + sanitized = sanitized.replace('\r', '\\r') + sanitized = sanitized.replace('\t', '\\t') + + elif contentType == "document": + # For document content, escape special characters + sanitized = sanitized.replace('\\', '\\\\') + sanitized = sanitized.replace('"', '\\"') + sanitized = sanitized.replace("'", "\\'") + sanitized = sanitized.replace('\n', '\\n') + sanitized = sanitized.replace('\r', '\\r') + sanitized = sanitized.replace('\t', '\\t') + + else: # contentType == "text" or default + # Basic text sanitization + sanitized = sanitized.replace('\\', '\\\\') + sanitized = sanitized.replace('"', '\\"') + sanitized = sanitized.replace("'", "\\'") + sanitized = sanitized.replace('\n', '\\n') + sanitized = sanitized.replace('\r', '\\r') + sanitized = sanitized.replace('\t', '\\t') + + return sanitized + + except Exception as e: + logger.error(f"Error sanitizing prompt content: {str(e)}") + # Return a safe fallback + return "[ERROR: Content could not be safely sanitized]" + + +def extractTextFromContentParts(extracted_content) -> str: + """ + Extract text content from ExtractionService ContentPart objects. + + Args: + extracted_content: ContentExtracted object with parts + + Returns: + Concatenated text content from all text/table/structure parts + """ + 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(text: str, reduction_factor: float) -> str: + """ + Reduce text size by the specified factor. + + Args: + text: Text to reduce + reduction_factor: Factor by which to reduce (0.0 to 1.0) + + Returns: + Reduced text with truncation indicator + """ + if reduction_factor >= 1.0: + return text + + target_length = int(len(text) * reduction_factor) + return text[:target_length] + "... [reduced]" + + +def determineCallType(documents: Optional[List], operation_type: str) -> str: + """ + Determine call type based on documents and operation type. + + Args: + documents: List of ChatDocument objects + operation_type: Type of operation being performed + + Returns: + Call type: "plan" or "text" + """ + has_documents = documents is not None and len(documents) > 0 + is_planning_operation = operation_type == "plan" + + if not has_documents and is_planning_operation: + return "plan" + else: + return "text" diff --git a/modules/services/serviceAi/subWebResearch.py b/modules/services/serviceAi/subWebResearch.py index 953324aa..000d828a 100644 --- a/modules/services/serviceAi/subWebResearch.py +++ b/modules/services/serviceAi/subWebResearch.py @@ -368,7 +368,7 @@ class SubWebResearch: ) document = WebResearchActionDocument( - documentName=f"web_research_{request.user_prompt[:50]}.json", + documentName=f"webResearch_{request.user_prompt[:50]}.json", documentData=documentData, mimeType="application/json" ) @@ -376,7 +376,7 @@ class SubWebResearch: return WebResearchActionResult( success=True, documents=[document], - resultLabel="web_research_results" + resultLabel="webResearch_results" ) except Exception as e: diff --git a/modules/services/serviceExtraction/subPipeline.py b/modules/services/serviceExtraction/subPipeline.py index 645b9bdf..09caf98d 100644 --- a/modules/services/serviceExtraction/subPipeline.py +++ b/modules/services/serviceExtraction/subPipeline.py @@ -214,7 +214,7 @@ def _applyMerging(parts: List[ContentPart], strategy: Dict[str, Any]) -> List[Co # Check if intelligent merging is enabled if strategy.get("useIntelligentMerging", False): - model_capabilities = strategy.get("modelCapabilities", {}) + model_capabilities = strategy.get("capabilities", {}) subMerger = IntelligentTokenAwareMerger(model_capabilities) # Use intelligent merging for all parts @@ -311,19 +311,19 @@ def applyAiIfRequested(extracted: ContentExtracted, options: Dict[str, Any]) -> return extracted # Placeholder AI processing based on operationType - if operationType == "analyse_content": + if operationType == "analyse": # Add analysis metadata to parts for part in extracted.parts: if part.typeGroup in ("text", "table", "structure"): part.metadata["ai_processed"] = True part.metadata["operation_type"] = operationType - elif operationType == "generate_plan": + elif operationType == "plan": # Add plan generation metadata for part in extracted.parts: if part.typeGroup == "text": part.metadata["ai_processed"] = True part.metadata["operation_type"] = operationType - elif operationType == "generate_content": + elif operationType == "generate": # Add content generation metadata for part in extracted.parts: part.metadata["ai_processed"] = True diff --git a/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py b/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py index d5d28cf8..35b950db 100644 --- a/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py +++ b/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py @@ -11,7 +11,7 @@ from datetime import datetime, UTC import base64 import io from PIL import Image -from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType +from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum logger = logging.getLogger(__name__) @@ -326,7 +326,7 @@ class BaseRenderer(ABC): try: request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL request = AiCallRequest(prompt=style_template, context="", options=request_options) diff --git a/modules/services/serviceGeneration/renderers/rendererDocx.py b/modules/services/serviceGeneration/renderers/rendererDocx.py index 90e09599..42bb71f3 100644 --- a/modules/services/serviceGeneration/renderers/rendererDocx.py +++ b/modules/services/serviceGeneration/renderers/rendererDocx.py @@ -565,7 +565,7 @@ class RendererDocx(BaseRenderer): return structure - def _generate_content_from_structure(self, doc, content: str, structure: Dict[str, Any]): + def _generate_from_structure(self, doc, content: str, structure: Dict[str, Any]): """Generate DOCX content based on extracted structure.""" # Add sections based on prompt structure for section in structure['sections']: diff --git a/modules/services/serviceGeneration/renderers/rendererImage.py b/modules/services/serviceGeneration/renderers/rendererImage.py index f47dd54d..6147d42b 100644 --- a/modules/services/serviceGeneration/renderers/rendererImage.py +++ b/modules/services/serviceGeneration/renderers/rendererImage.py @@ -57,7 +57,7 @@ class RendererImage(BaseRenderer): document_title = extracted_content.get("metadata", {}).get("title", title) # Create AI prompt for image generation - image_prompt = await self._create_image_generation_prompt(extracted_content, document_title, user_prompt, ai_service) + image_prompt = await self._create_imageGenerate_prompt(extracted_content, document_title, user_prompt, ai_service) # Save image generation prompt to debug ai_service.services.utils.writeDebugFile(image_prompt, "rendererImageGenerationPrompt") @@ -88,7 +88,7 @@ class RendererImage(BaseRenderer): self.logger.error(f"Error generating AI image: {str(e)}") raise Exception(f"AI image generation failed: {str(e)}") - async def _create_image_generation_prompt(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> str: + async def _create_imageGenerate_prompt(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> str: """Create a detailed prompt for AI image generation based on the content.""" try: # Start with base prompt @@ -174,12 +174,12 @@ Return only the compressed prompt, no explanations. # Use AI to compress the prompt - call the AI service correctly # The ai_service has an aiObjects attribute that contains the actual AI interface - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum request = AiCallRequest( prompt=compression_prompt, options=AiCallOptions( - operationType=OperationType.GENERAL, + operationType=OperationTypeEnum.GENERAL, maxTokens=None, # Let the model use its full context length temperature=0.3 # Lower temperature for more consistent compression ) diff --git a/modules/services/serviceGeneration/renderers/rendererPdf.py b/modules/services/serviceGeneration/renderers/rendererPdf.py index dc3195ae..e63e695f 100644 --- a/modules/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/services/serviceGeneration/renderers/rendererPdf.py @@ -157,10 +157,10 @@ class RendererPdf(BaseRenderer): return default_styles try: - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL request = AiCallRequest(prompt=style_template, context="", options=request_options) diff --git a/modules/services/serviceGeneration/renderers/rendererPptx.py b/modules/services/serviceGeneration/renderers/rendererPptx.py index 26c707ca..508d2580 100644 --- a/modules/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/services/serviceGeneration/renderers/rendererPptx.py @@ -357,10 +357,10 @@ JSON ONLY. NO OTHER TEXT.""" return default_styles try: - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL request = AiCallRequest(prompt=style_template, context="", options=request_options) diff --git a/modules/services/serviceGeneration/renderers/rendererXlsx.py b/modules/services/serviceGeneration/renderers/rendererXlsx.py index ddd6e9f3..4e5343fb 100644 --- a/modules/services/serviceGeneration/renderers/rendererXlsx.py +++ b/modules/services/serviceGeneration/renderers/rendererXlsx.py @@ -274,10 +274,10 @@ class RendererXlsx(BaseRenderer): return default_styles try: - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL request = AiCallRequest(prompt=style_template, context="", options=request_options) response = await ai_service.aiObjects.call(request) diff --git a/modules/services/serviceGeneration/subPromptBuilder.py b/modules/services/serviceGeneration/subPromptBuilder.py index d326772c..33c506c5 100644 --- a/modules/services/serviceGeneration/subPromptBuilder.py +++ b/modules/services/serviceGeneration/subPromptBuilder.py @@ -6,7 +6,7 @@ This module builds prompts for AI services to extract and generate documents. import json import logging from typing import Dict, Any, Optional, List, TYPE_CHECKING -from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType +from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum # Type hint for renderer parameter if TYPE_CHECKING: @@ -380,10 +380,8 @@ Extract the main intent and requirements for document processing. Focus on: Respond with a clear, concise statement of the extraction intent. """ - - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL request = AiCallRequest(prompt=analysis_prompt, context="", options=request_options) response = await aiService.aiObjects.call(request) diff --git a/modules/services/serviceWorkflow/mainServiceWorkflow.py b/modules/services/serviceWorkflow/mainServiceWorkflow.py index c30028b9..a29df0c5 100644 --- a/modules/services/serviceWorkflow/mainServiceWorkflow.py +++ b/modules/services/serviceWorkflow/mainServiceWorkflow.py @@ -62,7 +62,7 @@ Please provide a comprehensive summary of this conversation.""" documents=None, options={ "process_type": "text", - "operation_type": "generate_content", + "operation_type": "generate", "priority": "speed", "compress_prompt": True, "compress_documents": False, diff --git a/modules/workflows/methods/methodAi.py b/modules/workflows/methods/methodAi.py index 738a4f36..e5c4cf71 100644 --- a/modules/workflows/methods/methodAi.py +++ b/modules/workflows/methods/methodAi.py @@ -10,7 +10,7 @@ from datetime import datetime, UTC from modules.workflows.methods.methodBase import MethodBase, action from modules.datamodels.datamodelChat import ActionResult -from modules.datamodels.datamodelAi import AiCallOptions, OperationType, Priority +from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum, ModelCapabilitiesEnum from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelWeb import WebResearchRequest, WebResearchOptions @@ -42,11 +42,11 @@ class MethodAi(MethodBase): - resultType (str, optional): Output file extension - only one extension allowed (e.g. txt, json, md, csv, xml, html, pdf, docx, xlsx, png, ...). Default: txt. - processingMode (str, optional): basic | advanced | detailed. Default: basic. - includeMetadata (bool, optional): Include metadata when available. Default: True. - - operationType (str, optional): general | generate_plan | analyse_content | generate_content | web_research | image_analysis | image_generation. Default: general. + - operationType (str, optional): general | plan | analyse | generate | webResearch | imageAnalyse | imageGenerate. Default: general. - priority (str, optional): speed | quality | cost | balanced. Default: balanced. - maxCost (float, optional): Cost limit. - maxProcessingTime (int, optional): Time limit in seconds. - - requiredTags (list, optional): Capability tags (e.g., text, chat, reasoning, analysis, image, vision, web, search). + - operationTypes (list, optional): Capability tags (e.g., text, chat, reasoning, analysis, image, vision, web, search). """ try: # Init progress logger @@ -75,13 +75,55 @@ class MethodAi(MethodBase): if isinstance(documentList, str): documentList = [documentList] resultType = parameters.get("resultType", "txt") - processingMode = parameters.get("processingMode", "basic") + processingModeStr = parameters.get("processingMode", "basic") includeMetadata = parameters.get("includeMetadata", True) - operationType = parameters.get("operationType", "general") - priority = parameters.get("priority", "balanced") + operationTypeStr = parameters.get("operationType", "general") + priorityStr = parameters.get("priority", "balanced") maxCost = parameters.get("maxCost") maxProcessingTime = parameters.get("maxProcessingTime") - requiredTags = parameters.get("requiredTags") + operationTypes = parameters.get("operationTypes") + requiredTags = parameters.get("requiredTags", []) + + # Map string parameters to enums + operationTypeMapping = { + "general": OperationTypeEnum.GENERAL, + "plan": OperationTypeEnum.PLAN, + "analyse": OperationTypeEnum.ANALYSE, + "generate": OperationTypeEnum.GENERATE, + "webResearch": OperationTypeEnum.WEB_RESEARCH, + "imageAnalyse": OperationTypeEnum.IMAGE_ANALYSE, + "imageGenerate": OperationTypeEnum.IMAGE_GENERATE + } + operationType = operationTypeMapping.get(operationTypeStr, OperationTypeEnum.GENERAL) + + priorityMapping = { + "speed": PriorityEnum.SPEED, + "quality": PriorityEnum.QUALITY, + "cost": PriorityEnum.COST, + "balanced": PriorityEnum.BALANCED + } + priority = priorityMapping.get(priorityStr, PriorityEnum.BALANCED) + + processingModeMapping = { + "basic": ProcessingModeEnum.BASIC, + "advanced": ProcessingModeEnum.ADVANCED, + "detailed": ProcessingModeEnum.DETAILED + } + processingMode = processingModeMapping.get(processingModeStr, ProcessingModeEnum.BASIC) + + # Map requiredTags from strings to ModelCapabilitiesEnum + if requiredTags and isinstance(requiredTags, list): + tagMapping = { + "text": ModelCapabilitiesEnum.TEXT_GENERATION, + "chat": ModelCapabilitiesEnum.CHAT, + "reasoning": ModelCapabilitiesEnum.REASONING, + "analysis": ModelCapabilitiesEnum.ANALYSIS, + "image": ModelCapabilitiesEnum.VISION, + "vision": ModelCapabilitiesEnum.VISION, + "web": ModelCapabilitiesEnum.WEB_SEARCH, + "search": ModelCapabilitiesEnum.WEB_SEARCH + } + requiredTags = [tagMapping.get(tag, tag) for tag in requiredTags if isinstance(tag, str)] if not aiPrompt: logger.error(f"aiPrompt is missing or empty. Parameters: {parameters}") @@ -113,14 +155,14 @@ class MethodAi(MethodBase): options = AiCallOptions( operationType=operationType, priority=priority, - compressPrompt=processingMode != "detailed", + compressPrompt=processingMode != ProcessingModeEnum.DETAILED, compressContext=True, processDocumentsIndividually=True, processingMode=processingMode, resultFormat=output_format, maxCost=maxCost, maxProcessingTime=maxProcessingTime, - requiredTags=requiredTags + capabilities=requiredTags if requiredTags else None ) # Update progress - calling AI diff --git a/modules/workflows/processing/adaptive/contentValidator.py b/modules/workflows/processing/adaptive/contentValidator.py index 2a9b5d83..1f6a0aed 100644 --- a/modules/workflows/processing/adaptive/contentValidator.py +++ b/modules/workflows/processing/adaptive/contentValidator.py @@ -116,9 +116,9 @@ DELIVERED CONTENT TO CHECK: """ # Call AI service for validation - from modules.datamodels.datamodelAi import AiCallOptions, OperationType + from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL response = await self.services.ai.callAiPlanning( prompt=validationPrompt, diff --git a/modules/workflows/processing/adaptive/intentAnalyzer.py b/modules/workflows/processing/adaptive/intentAnalyzer.py index bbe78651..4bc7aa55 100644 --- a/modules/workflows/processing/adaptive/intentAnalyzer.py +++ b/modules/workflows/processing/adaptive/intentAnalyzer.py @@ -59,9 +59,9 @@ CRITICAL: Respond with ONLY the JSON object below. Do not include any explanator """ # Call AI service for analysis - from modules.datamodels.datamodelAi import AiCallOptions, OperationType + from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL + request_options.operationType = OperationTypeEnum.GENERAL response = await self.services.ai.callAiPlanning( prompt=analysisPrompt, diff --git a/modules/workflows/processing/adaptive/progressTracker.py b/modules/workflows/processing/adaptive/progressTracker.py index 69444e7f..a94dacd8 100644 --- a/modules/workflows/processing/adaptive/progressTracker.py +++ b/modules/workflows/processing/adaptive/progressTracker.py @@ -15,7 +15,7 @@ class ProgressTracker: self.partialAchievements = [] self.failedAttempts = [] self.learningInsights = [] - self.currentPhase = "planning" + self.currentPhase = "plan" def updateOperation(self, result: Any, validation: Dict[str, Any], intent: Dict[str, Any]): """Updates progress tracking based on action result""" @@ -154,4 +154,4 @@ class ProgressTracker: self.partialAchievements = [] self.failedAttempts = [] self.learningInsights = [] - self.currentPhase = "planning" + self.currentPhase = "plan" diff --git a/modules/workflows/processing/core/taskPlanner.py b/modules/workflows/processing/core/taskPlanner.py index 41f7f851..6738f9a2 100644 --- a/modules/workflows/processing/core/taskPlanner.py +++ b/modules/workflows/processing/core/taskPlanner.py @@ -5,7 +5,7 @@ import json import logging from typing import Dict, Any from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan -from modules.datamodels.datamodelAi import AiCallOptions, OperationType, ProcessingMode, Priority +from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum, PriorityEnum from modules.workflows.processing.shared.promptGenerationTaskplan import ( generateTaskPlanningPrompt ) @@ -59,7 +59,7 @@ class TaskPlanner: # Create proper context object for task planning using cleaned intent # For task planning, we need to create a minimal TaskStep since TaskContext requires it planningTaskStep = TaskStep( - id="planning", + id="plan", objective=cleanedObjective, dependencies=[], success_criteria=[], @@ -96,11 +96,11 @@ class TaskPlanner: # Centralized AI call: Task planning (quality, detailed) with placeholders options = AiCallOptions( - operationType=OperationType.GENERATE_PLAN, - priority=Priority.QUALITY, + operationType=OperationTypeEnum.PLAN, + priority=PriorityEnum.QUALITY, compressPrompt=False, compressContext=False, - processingMode=ProcessingMode.DETAILED, + processingMode=ProcessingModeEnum.DETAILED, maxCost=0.10, maxProcessingTime=30 ) diff --git a/modules/workflows/processing/modes/modeActionplan.py b/modules/workflows/processing/modes/modeActionplan.py index aa04a070..aaf25254 100644 --- a/modules/workflows/processing/modes/modeActionplan.py +++ b/modules/workflows/processing/modes/modeActionplan.py @@ -10,7 +10,7 @@ from modules.datamodels.datamodelChat import ( ActionResult, ReviewResult, ReviewContext ) from modules.datamodels.datamodelChat import ChatWorkflow -from modules.datamodels.datamodelAi import AiCallOptions, OperationType, ProcessingMode, Priority +from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum, PriorityEnum from modules.workflows.processing.modes.modeBase import BaseMode from modules.workflows.processing.shared.executionState import TaskExecutionState from modules.workflows.processing.shared.promptGenerationActionsActionplan import ( @@ -125,11 +125,11 @@ class ActionplanMode(BaseMode): # Centralized AI call: Action planning (quality, detailed) with placeholders options = AiCallOptions( - operationType=OperationType.GENERATE_PLAN, - priority=Priority.QUALITY, + operationType=OperationTypeEnum.PLAN, + priority=PriorityEnum.QUALITY, compressPrompt=False, compressContext=False, - processingMode=ProcessingMode.DETAILED, + processingMode=ProcessingModeEnum.DETAILED, maxCost=0.10, maxProcessingTime=30 ) @@ -457,11 +457,11 @@ class ActionplanMode(BaseMode): # Centralized AI call: Result validation (balanced analysis) with placeholders options = AiCallOptions( - operationType=OperationType.ANALYSE_CONTENT, - priority=Priority.BALANCED, + operationType=OperationTypeEnum.ANALYSE, + priority=PriorityEnum.BALANCED, compressPrompt=True, compressContext=False, - processingMode=ProcessingMode.ADVANCED, + processingMode=ProcessingModeEnum.ADVANCED, maxCost=0.05, maxProcessingTime=30 ) diff --git a/modules/workflows/processing/modes/modeReact.py b/modules/workflows/processing/modes/modeReact.py index 4b581116..de2b0db9 100644 --- a/modules/workflows/processing/modes/modeReact.py +++ b/modules/workflows/processing/modes/modeReact.py @@ -12,7 +12,7 @@ from modules.datamodels.datamodelChat import ( ActionResult ) from modules.datamodels.datamodelChat import ChatWorkflow -from modules.datamodels.datamodelAi import AiCallOptions, OperationType, ProcessingMode, Priority +from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum, PriorityEnum from modules.workflows.processing.modes.modeBase import BaseMode from modules.workflows.processing.shared.executionState import TaskExecutionState, shouldContinue from modules.workflows.processing.shared.promptGenerationActionsReact import ( @@ -187,11 +187,11 @@ class ReactMode(BaseMode): # Centralized AI call for plan selection (use plan generation quality) options = AiCallOptions( - operationType=OperationType.GENERATE_PLAN, - priority=Priority.QUALITY, + operationType=OperationTypeEnum.PLAN, + priority=PriorityEnum.QUALITY, compressPrompt=False, compressContext=False, - processingMode=ProcessingMode.DETAILED, + processingMode=ProcessingModeEnum.DETAILED, maxCost=0.10, maxProcessingTime=30 ) @@ -296,11 +296,11 @@ class ReactMode(BaseMode): # Centralized AI call for parameter suggestion (balanced analysis) options = AiCallOptions( - operationType=OperationType.ANALYSE_CONTENT, - priority=Priority.BALANCED, + operationType=OperationTypeEnum.ANALYSE, + priority=PriorityEnum.BALANCED, compressPrompt=True, compressContext=False, - processingMode=ProcessingMode.ADVANCED, + processingMode=ProcessingModeEnum.ADVANCED, maxCost=0.05, maxProcessingTime=30, temperature=0.3, # Slightly higher temperature for better instruction following @@ -611,11 +611,11 @@ class ReactMode(BaseMode): # Centralized AI call for refinement decision (balanced analysis) options = AiCallOptions( - operationType=OperationType.ANALYSE_CONTENT, - priority=Priority.BALANCED, + operationType=OperationTypeEnum.ANALYSE, + priority=PriorityEnum.BALANCED, compressPrompt=True, compressContext=False, - processingMode=ProcessingMode.ADVANCED, + processingMode=ProcessingModeEnum.ADVANCED, maxCost=0.05, maxProcessingTime=30 ) @@ -718,8 +718,8 @@ Return only the user-friendly message, no technical details.""" prompt=prompt, placeholders=None, options=AiCallOptions( - operationType=OperationType.GENERATE_CONTENT, - priority=Priority.SPEED, + operationType=OperationTypeEnum.GENERATE, + priority=PriorityEnum.SPEED, compressPrompt=True, maxCost=0.01, maxProcessingTime=5 @@ -759,8 +759,8 @@ Return only the user-friendly message, no technical details.""" prompt=prompt, placeholders=None, options=AiCallOptions( - operationType=OperationType.GENERATE_CONTENT, - priority=Priority.SPEED, + operationType=OperationTypeEnum.GENERATE, + priority=PriorityEnum.SPEED, compressPrompt=True, maxCost=0.01, maxProcessingTime=5