Phase 0: Update credit costs and CreditService, add credit checks to AI Engine
- Updated CREDIT_COSTS to match Phase 0 spec (flat structure) - Added get_credit_cost() method to CreditService - Updated check_credits() to accept operation_type and amount - Added deduct_credits_for_operation() convenience method - Updated AI Engine to check credits BEFORE AI call - Updated AI Engine to deduct credits AFTER successful execution - Added helper methods for operation type mapping and amount calculation
This commit is contained in:
@@ -192,6 +192,31 @@ class AIEngine:
|
||||
self.step_tracker.add_request_step("PREP", "success", prep_message)
|
||||
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
|
||||
|
||||
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
|
||||
if self.account:
|
||||
try:
|
||||
from igny8_core.modules.billing.services import CreditService
|
||||
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
# Map function name to operation type
|
||||
operation_type = self._get_operation_type(function_name)
|
||||
|
||||
# Calculate estimated cost
|
||||
estimated_amount = self._get_estimated_amount(function_name, data, payload)
|
||||
|
||||
# Check credits BEFORE AI call
|
||||
CreditService.check_credits(self.account, operation_type, estimated_amount)
|
||||
|
||||
logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}")
|
||||
except InsufficientCreditsError as e:
|
||||
error_msg = str(e)
|
||||
error_type = 'InsufficientCreditsError'
|
||||
logger.error(f"[AIEngine] {error_msg}")
|
||||
return self._handle_error(error_msg, fn, error_type=error_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AIEngine] Failed to check credits: {e}", exc_info=True)
|
||||
# Don't fail the operation if credit check fails (for backward compatibility)
|
||||
|
||||
# Phase 3: AI_CALL - Provider API Call (25-70%)
|
||||
# Validate account exists before proceeding
|
||||
if not self.account:
|
||||
@@ -325,37 +350,45 @@ class AIEngine:
|
||||
# Store save_msg for use in DONE phase
|
||||
final_save_msg = save_msg
|
||||
|
||||
# Track credit usage after successful save
|
||||
# Phase 5.5: DEDUCT CREDITS - Deduct credits after successful save
|
||||
if self.account and raw_response:
|
||||
try:
|
||||
from igny8_core.modules.billing.services import CreditService
|
||||
from igny8_core.modules.billing.models import CreditUsageLog
|
||||
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
# Calculate credits used (based on tokens or fixed cost)
|
||||
credits_used = self._calculate_credits_for_clustering(
|
||||
keyword_count=len(data.get('keywords', [])) if isinstance(data, dict) else len(data) if isinstance(data, list) else 1,
|
||||
tokens=raw_response.get('total_tokens', 0),
|
||||
cost=raw_response.get('cost', 0)
|
||||
)
|
||||
# Map function name to operation type
|
||||
operation_type = self._get_operation_type(function_name)
|
||||
|
||||
# Log credit usage (don't deduct from account.credits, just log)
|
||||
CreditUsageLog.objects.create(
|
||||
# Calculate actual amount based on results
|
||||
actual_amount = self._get_actual_amount(function_name, save_result, parsed, data)
|
||||
|
||||
# Deduct credits using the new convenience method
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='clustering',
|
||||
credits_used=credits_used,
|
||||
operation_type=operation_type,
|
||||
amount=actual_amount,
|
||||
cost_usd=raw_response.get('cost'),
|
||||
model_used=raw_response.get('model', ''),
|
||||
tokens_input=raw_response.get('tokens_input', 0),
|
||||
tokens_output=raw_response.get('tokens_output', 0),
|
||||
related_object_type='cluster',
|
||||
related_object_type=self._get_related_object_type(function_name),
|
||||
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
|
||||
metadata={
|
||||
'function_name': function_name,
|
||||
'clusters_created': clusters_created,
|
||||
'keywords_updated': keywords_updated,
|
||||
'function_name': function_name
|
||||
'count': count,
|
||||
**save_result
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[AIEngine] Credits deducted: {operation_type}, amount: {actual_amount}")
|
||||
except InsufficientCreditsError as e:
|
||||
# This shouldn't happen since we checked before, but log it
|
||||
logger.error(f"[AIEngine] Insufficient credits during deduction: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log credit usage: {e}", exc_info=True)
|
||||
logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True)
|
||||
# Don't fail the operation if credit deduction fails (for backward compatibility)
|
||||
|
||||
# Phase 6: DONE - Finalization (98-100%)
|
||||
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
|
||||
@@ -453,18 +486,74 @@ class AIEngine:
|
||||
# Don't fail the task if logging fails
|
||||
logger.warning(f"Failed to log to database: {e}")
|
||||
|
||||
def _calculate_credits_for_clustering(self, keyword_count, tokens, cost):
|
||||
"""Calculate credits used for clustering operation"""
|
||||
# Use plan's cost per request if available, otherwise calculate from tokens
|
||||
if self.account and hasattr(self.account, 'plan') and self.account.plan:
|
||||
plan = self.account.plan
|
||||
# Check if plan has ai_cost_per_request config
|
||||
if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request:
|
||||
cluster_cost = plan.ai_cost_per_request.get('cluster', 0)
|
||||
if cluster_cost:
|
||||
return int(cluster_cost)
|
||||
|
||||
# Fallback: 1 credit per 30 keywords (minimum 1)
|
||||
credits = max(1, int(keyword_count / 30))
|
||||
return credits
|
||||
def _get_operation_type(self, function_name):
|
||||
"""Map function name to operation type for credit system"""
|
||||
mapping = {
|
||||
'auto_cluster': 'clustering',
|
||||
'generate_ideas': 'idea_generation',
|
||||
'generate_content': 'content_generation',
|
||||
'generate_image_prompts': 'image_prompt_extraction',
|
||||
'generate_images': 'image_generation',
|
||||
}
|
||||
return mapping.get(function_name, function_name)
|
||||
|
||||
def _get_estimated_amount(self, function_name, data, payload):
|
||||
"""Get estimated amount for credit calculation (before operation)"""
|
||||
if function_name == 'generate_content':
|
||||
# Estimate word count from task or default
|
||||
if isinstance(data, dict):
|
||||
return data.get('estimated_word_count', 1000)
|
||||
return 1000 # Default estimate
|
||||
elif function_name == 'generate_images':
|
||||
# Count images to generate
|
||||
if isinstance(payload, dict):
|
||||
image_ids = payload.get('image_ids', [])
|
||||
return len(image_ids) if image_ids else 1
|
||||
return 1
|
||||
elif function_name == 'generate_ideas':
|
||||
# Count clusters
|
||||
if isinstance(data, dict) and 'cluster_data' in data:
|
||||
return len(data['cluster_data'])
|
||||
return 1
|
||||
# For fixed cost operations (clustering, image_prompt_extraction), return None
|
||||
return None
|
||||
|
||||
def _get_actual_amount(self, function_name, save_result, parsed, data):
|
||||
"""Get actual amount for credit calculation (after operation)"""
|
||||
if function_name == 'generate_content':
|
||||
# Get actual word count from saved content
|
||||
if isinstance(save_result, dict):
|
||||
word_count = save_result.get('word_count')
|
||||
if word_count:
|
||||
return word_count
|
||||
# Fallback: estimate from parsed content
|
||||
if isinstance(parsed, dict) and 'content' in parsed:
|
||||
content = parsed['content']
|
||||
return len(content.split()) if isinstance(content, str) else 1000
|
||||
return 1000
|
||||
elif function_name == 'generate_images':
|
||||
# Count successfully generated images
|
||||
count = save_result.get('count', 0)
|
||||
if count > 0:
|
||||
return count
|
||||
return 1
|
||||
elif function_name == 'generate_ideas':
|
||||
# Count ideas generated
|
||||
count = save_result.get('count', 0)
|
||||
if count > 0:
|
||||
return count
|
||||
return 1
|
||||
# For fixed cost operations, return None
|
||||
return None
|
||||
|
||||
def _get_related_object_type(self, function_name):
|
||||
"""Get related object type for credit logging"""
|
||||
mapping = {
|
||||
'auto_cluster': 'cluster',
|
||||
'generate_ideas': 'content_idea',
|
||||
'generate_content': 'content',
|
||||
'generate_image_prompts': 'image',
|
||||
'generate_images': 'image',
|
||||
}
|
||||
return mapping.get(function_name, 'unknown')
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
"""
|
||||
Credit Cost Constants
|
||||
Phase 0: Credit-only system costs per operation
|
||||
"""
|
||||
CREDIT_COSTS = {
|
||||
'clustering': {
|
||||
'base': 1, # 1 credit per 30 keywords
|
||||
'per_keyword': 1 / 30,
|
||||
},
|
||||
'ideas': {
|
||||
'base': 1, # 1 credit per idea
|
||||
},
|
||||
'content': {
|
||||
'base': 3, # 3 credits per full blog post
|
||||
},
|
||||
'images': {
|
||||
'base': 1, # 1 credit per image
|
||||
},
|
||||
'reparse': {
|
||||
'base': 1, # 1 credit per reparse
|
||||
},
|
||||
'clustering': 10, # Per clustering request
|
||||
'idea_generation': 15, # Per cluster → ideas request
|
||||
'content_generation': 1, # Per 100 words
|
||||
'image_prompt_extraction': 2, # Per content piece
|
||||
'image_generation': 5, # Per image
|
||||
'linking': 8, # Per content piece (NEW)
|
||||
'optimization': 1, # Per 200 words (NEW)
|
||||
'site_structure_generation': 50, # Per site blueprint (NEW)
|
||||
'site_page_generation': 20, # Per page (NEW)
|
||||
# Legacy operation types (for backward compatibility)
|
||||
'ideas': 15, # Alias for idea_generation
|
||||
'content': 3, # Legacy: 3 credits per content piece
|
||||
'images': 5, # Alias for image_generation
|
||||
'reparse': 1, # Per reparse
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,65 @@ class CreditService:
|
||||
"""Service for managing credits"""
|
||||
|
||||
@staticmethod
|
||||
def check_credits(account, required_credits):
|
||||
def get_credit_cost(operation_type, amount=None):
|
||||
"""
|
||||
Check if account has enough credits.
|
||||
Get credit cost for operation.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation (from CREDIT_COSTS)
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
|
||||
Returns:
|
||||
int: Number of credits required
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If operation type is unknown
|
||||
"""
|
||||
base_cost = CREDIT_COSTS.get(operation_type, 0)
|
||||
if base_cost == 0:
|
||||
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
||||
|
||||
# Variable cost operations
|
||||
if operation_type == 'content_generation' and amount:
|
||||
# Per 100 words
|
||||
return max(1, int(base_cost * (amount / 100)))
|
||||
elif operation_type == 'optimization' and amount:
|
||||
# Per 200 words
|
||||
return max(1, int(base_cost * (amount / 200)))
|
||||
elif operation_type == 'image_generation' and amount:
|
||||
# Per image
|
||||
return base_cost * amount
|
||||
elif operation_type == 'idea_generation' and amount:
|
||||
# Per idea
|
||||
return base_cost * amount
|
||||
|
||||
# Fixed cost operations
|
||||
return base_cost
|
||||
|
||||
@staticmethod
|
||||
def check_credits(account, operation_type, amount=None):
|
||||
"""
|
||||
Check if account has sufficient credits for an operation.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
required = CreditService.get_credit_cost(operation_type, amount)
|
||||
if account.credits < required:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {required}, Available: {account.credits}"
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_credits_legacy(account, required_credits):
|
||||
"""
|
||||
Legacy method: Check if account has enough credits (for backward compatibility).
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
@@ -51,8 +107,8 @@ class CreditService:
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Check sufficient credits
|
||||
CreditService.check_credits(account, amount)
|
||||
# Check sufficient credits (legacy: amount is already calculated)
|
||||
CreditService.check_credits_legacy(account, amount)
|
||||
|
||||
# Deduct from account.credits
|
||||
account.credits -= amount
|
||||
@@ -84,6 +140,61 @@ class CreditService:
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||
"""
|
||||
Deduct credits for an operation (convenience method that calculates cost automatically).
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
description: Optional description (auto-generated if not provided)
|
||||
metadata: Optional metadata dict
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
tokens_input: Optional input tokens
|
||||
tokens_output: Optional output tokens
|
||||
related_object_type: Optional related object type
|
||||
related_object_id: Optional related object ID
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Calculate credit cost
|
||||
credits_required = CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
# Check sufficient credits
|
||||
CreditService.check_credits(account, operation_type, amount)
|
||||
|
||||
# Auto-generate description if not provided
|
||||
if not description:
|
||||
if operation_type == 'clustering':
|
||||
description = f"Clustering operation"
|
||||
elif operation_type == 'idea_generation':
|
||||
description = f"Generated {amount or 1} idea(s)"
|
||||
elif operation_type == 'content_generation':
|
||||
description = f"Generated content ({amount or 0} words)"
|
||||
elif operation_type == 'image_generation':
|
||||
description = f"Generated {amount or 1} image(s)"
|
||||
else:
|
||||
description = f"{operation_type} operation"
|
||||
|
||||
return CreditService.deduct_credits(
|
||||
account=account,
|
||||
amount=credits_required,
|
||||
operation_type=operation_type,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_used,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type,
|
||||
related_object_id=related_object_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def add_credits(account, amount, transaction_type, description, metadata=None):
|
||||
@@ -120,6 +231,7 @@ class CreditService:
|
||||
def calculate_credits_for_operation(operation_type, **kwargs):
|
||||
"""
|
||||
Calculate credits needed for an operation.
|
||||
Legacy method - use get_credit_cost() instead.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation
|
||||
@@ -131,31 +243,22 @@ class CreditService:
|
||||
Raises:
|
||||
CreditCalculationError: If calculation fails
|
||||
"""
|
||||
if operation_type not in CREDIT_COSTS:
|
||||
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
||||
|
||||
cost_config = CREDIT_COSTS[operation_type]
|
||||
|
||||
if operation_type == 'clustering':
|
||||
# 1 credit per 30 keywords
|
||||
keyword_count = kwargs.get('keyword_count', 0)
|
||||
credits = max(1, int(keyword_count * cost_config['per_keyword']))
|
||||
return credits
|
||||
elif operation_type == 'ideas':
|
||||
# 1 credit per idea
|
||||
idea_count = kwargs.get('idea_count', 1)
|
||||
return cost_config['base'] * idea_count
|
||||
# Map legacy operation types
|
||||
if operation_type == 'ideas':
|
||||
operation_type = 'idea_generation'
|
||||
elif operation_type == 'content':
|
||||
# 3 credits per content piece
|
||||
content_count = kwargs.get('content_count', 1)
|
||||
return cost_config['base'] * content_count
|
||||
operation_type = 'content_generation'
|
||||
elif operation_type == 'images':
|
||||
# 1 credit per image
|
||||
image_count = kwargs.get('image_count', 1)
|
||||
return cost_config['base'] * image_count
|
||||
elif operation_type == 'reparse':
|
||||
# 1 credit per reparse
|
||||
return cost_config['base']
|
||||
operation_type = 'image_generation'
|
||||
|
||||
return cost_config['base']
|
||||
# Extract amount from kwargs
|
||||
amount = None
|
||||
if 'word_count' in kwargs:
|
||||
amount = kwargs.get('word_count')
|
||||
elif 'image_count' in kwargs:
|
||||
amount = kwargs.get('image_count')
|
||||
elif 'idea_count' in kwargs:
|
||||
amount = kwargs.get('idea_count')
|
||||
|
||||
return CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user