implemented dynmaic ai integration and selection chain

This commit is contained in:
ValueOn AG 2025-10-22 01:15:21 +02:00
parent 3adaaad8eb
commit 109e77fd60
34 changed files with 1331 additions and 2316 deletions

View file

@ -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."""

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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
)
]

View file

@ -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"<html><body>{content}</body></html>"
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

View file

@ -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
)
]

View file

@ -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
)
]

View file

@ -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)}")

View file

@ -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."""

File diff suppressed because it is too large Load diff

View file

@ -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 <TOKEN_LIMIT> 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 "<TOKEN_LIMIT>" in modelPrompt:
modelPrompt = modelPrompt.replace("<TOKEN_LIMIT>", token_limit)
logger.debug(f"Replaced <TOKEN_LIMIT> 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 <TOKEN_LIMIT> 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 "<TOKEN_LIMIT>" in modelPrompt:
modelPrompt = modelPrompt.replace("<TOKEN_LIMIT>", tokenLimit)
logger.debug(f"Replaced <TOKEN_LIMIT> 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: <a href="url"> format
html_link_pattern = r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>'
html_links = re.findall(html_link_pattern, content, re.IGNORECASE)
htmlLinkPattern = r'<a[^>]+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

View file

@ -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]"

View file

@ -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]"

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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']:

View file

@ -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
)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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"

View file

@ -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
)

View file

@ -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
)

View file

@ -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