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(' 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