From 5b11c4001e661688a39cc54eb6cf0748ba490682 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 16 Nov 2025 18:37:41 +0000 Subject: [PATCH] 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 --- backend/igny8_core/ai/engine.py | 147 ++++++++++++---- .../igny8_core/modules/billing/constants.py | 31 ++-- .../igny8_core/modules/billing/services.py | 159 +++++++++++++++--- 3 files changed, 264 insertions(+), 73 deletions(-) diff --git a/backend/igny8_core/ai/engine.py b/backend/igny8_core/ai/engine.py index 4161df28..39542098 100644 --- a/backend/igny8_core/ai/engine.py +++ b/backend/igny8_core/ai/engine.py @@ -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') diff --git a/backend/igny8_core/modules/billing/constants.py b/backend/igny8_core/modules/billing/constants.py index 13f60131..ff669180 100644 --- a/backend/igny8_core/modules/billing/constants.py +++ b/backend/igny8_core/modules/billing/constants.py @@ -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 } diff --git a/backend/igny8_core/modules/billing/services.py b/backend/igny8_core/modules/billing/services.py index 79a5651b..64a8024e 100644 --- a/backend/igny8_core/modules/billing/services.py +++ b/backend/igny8_core/modules/billing/services.py @@ -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)