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:
IGNY8 VPS (Salman)
2025-11-16 18:37:41 +00:00
parent f84be4194f
commit 5b11c4001e
3 changed files with 264 additions and 73 deletions

View File

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