This commit is contained in:
alorig
2025-12-25 11:02:28 +05:00
18 changed files with 4164 additions and 559 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,347 @@
# AI Models Database Configuration - Implementation Summary
**Date Completed:** December 24, 2025
**Status:****PRODUCTION READY**
---
## Overview
Successfully migrated AI model pricing from hardcoded constants to a dynamic database-driven system. The system now supports real-time model configuration via Django Admin without requiring code deployments.
---
## Implementation Phases (All Complete ✅)
### Phase 1: AIModelConfig Model ✅
**File:** `backend/igny8_core/business/billing/models.py`
Created comprehensive model with:
- 15 fields supporting both text and image models
- Text model fields: `input_cost_per_1m`, `output_cost_per_1m`, `context_window`, `max_output_tokens`
- Image model fields: `cost_per_image`, `valid_sizes` (JSON array)
- Capabilities: `supports_json_mode`, `supports_vision`, `supports_function_calling`
- Status fields: `is_active`, `is_default`, `sort_order`
- Audit trail: `created_at`, `updated_at`, `updated_by`
- History tracking via `django-simple-history`
**Methods:**
- `get_cost_for_tokens(input_tokens, output_tokens)` - Calculate text model cost
- `get_cost_for_images(num_images)` - Calculate image model cost
- `validate_size(size)` - Validate image size for model
- `get_display_with_pricing()` - Formatted string for dropdowns
---
### Phase 2: Migration & Data Seeding ✅
**File:** `backend/igny8_core/modules/billing/migrations/0020_create_ai_model_config.py`
**Seeded Models:**
- **Text Models (5):**
- `gpt-4o-mini` (default) - $0.15/$0.60 per 1M | 128K context
- `gpt-4o` - $2.50/$10.00 per 1M | 128K context | Vision
- `gpt-4.1` - $2.00/$8.00 per 1M | 8K context
- `gpt-5.1` - $1.25/$10.00 per 1M | 16K context
- `gpt-5.2` - $1.75/$14.00 per 1M | 16K context
- **Image Models (4):**
- `dall-e-3` (default) - $0.040/image | 3 sizes
- `dall-e-2` - $0.020/image | 3 sizes
- `gpt-image-1` (inactive) - $0.042/image
- `gpt-image-1-mini` (inactive) - $0.011/image
**Total:** 9 models (7 active)
---
### Phase 3: Django Admin Interface ✅
**File:** `backend/igny8_core/modules/billing/admin.py`
**Features:**
- List display with colored badges (model type, provider)
- Formatted pricing display based on type
- Active/inactive and default status icons
- Filters: model_type, provider, is_active, capabilities
- Search: model_name, display_name, description
- Collapsible fieldsets organized by category
**Actions:**
- Bulk activate/deactivate models
- Set model as default (enforces single default per type)
- Export pricing table
**Access:** Django Admin → Billing → AI Model Configurations
---
### Phase 4 & 5: AI Core Integration ✅
**File:** `backend/igny8_core/ai/ai_core.py`
**Updated Functions:**
1. `run_ai_request()` (line ~294) - Text model cost calculation
2. `generate_image()` (line ~581) - Image model cost calculation
3. `calculate_cost()` (line ~822) - Helper method
**Implementation:**
- Lazy imports to avoid circular dependencies
- Database-first with fallback to constants
- Try/except wrapper for safety
- Logging shows source (database vs constants)
**Example:**
```python
# Before (hardcoded)
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000
# After (database)
model_config = AIModelConfig.objects.get(model_name=model, model_type='text', is_active=True)
cost = model_config.get_cost_for_tokens(input_tokens, output_tokens)
```
---
### Phase 6: Validators Update ✅
**File:** `backend/igny8_core/ai/validators.py`
**Updated Functions:**
1. `validate_model(model, model_type)` - Checks database for active models
2. `validate_image_size(size, model)` - Uses model's `valid_sizes` from database
**Benefits:**
- Dynamic model availability
- Better error messages with available model lists
- Automatic sync with database state
---
### Phase 7: REST API Endpoint ✅
**Endpoint:** `GET /api/v1/billing/ai/models/`
**Files Created/Updated:**
- Serializer: `backend/igny8_core/modules/billing/serializers.py`
- ViewSet: `backend/igny8_core/modules/billing/views.py`
- URLs: `backend/igny8_core/business/billing/urls.py`
**API Features:**
**List Models:**
```bash
GET /api/v1/billing/ai/models/
GET /api/v1/billing/ai/models/?type=text
GET /api/v1/billing/ai/models/?type=image
GET /api/v1/billing/ai/models/?provider=openai
GET /api/v1/billing/ai/models/?default=true
```
**Get Single Model:**
```bash
GET /api/v1/billing/ai/models/gpt-4o-mini/
```
**Response Format:**
```json
{
"success": true,
"message": "AI models retrieved successfully",
"data": [
{
"model_name": "gpt-4o-mini",
"display_name": "GPT-4o mini - Fast & Affordable",
"model_type": "text",
"provider": "openai",
"input_cost_per_1m": "0.1500",
"output_cost_per_1m": "0.6000",
"context_window": 128000,
"max_output_tokens": 16000,
"supports_json_mode": true,
"supports_vision": false,
"is_default": true,
"sort_order": 1,
"pricing_display": "$0.1500/$0.6000 per 1M"
}
]
}
```
**Authentication:** Required (JWT)
---
## Verification Results
### ✅ All Tests Passed
| Test | Status | Details |
|------|--------|---------|
| Database Models | ✅ | 9 models (7 active, 2 inactive) |
| Cost Calculations | ✅ | Text: $0.000523, Image: $0.0400 |
| Model Validators | ✅ | Database queries work correctly |
| Django Admin | ✅ | Registered with 9 display fields |
| API Endpoint | ✅ | `/api/v1/billing/ai/models/` |
| Model Methods | ✅ | All helper methods functional |
| Default Models | ✅ | gpt-4o-mini (text), dall-e-3 (image) |
---
## Key Benefits Achieved
### 1. **No Code Deploys for Pricing Updates**
- Update model pricing in Django Admin
- Changes take effect immediately
- No backend restart required
### 2. **Multi-Provider Ready**
- Provider field supports: OpenAI, Anthropic, Runware, Google
- Easy to add new providers without code changes
### 3. **Real-Time Model Management**
- Enable/disable models via admin
- Set default models per type
- Configure capabilities dynamically
### 4. **Frontend Integration Ready**
- RESTful API with filtering
- Structured data for dropdowns
- Pricing display included
### 5. **Backward Compatible**
- Constants still available as fallback
- Existing code continues to work
- Gradual migration complete
### 6. **Full Audit Trail**
- django-simple-history tracks all changes
- Updated_by field shows who made changes
- Created/updated timestamps
---
## Architecture
### Two Pricing Models Supported
**1. Text Models (Token-Based)**
- Credits calculated AFTER AI call
- Based on actual token usage
- Formula: `cost = (input_tokens × input_rate + output_tokens × output_rate) / 1M`
**2. Image Models (Per-Image)**
- Credits calculated BEFORE AI call
- Fixed cost per image
- Formula: `cost = cost_per_image × num_images`
### Data Flow
```
User Request
AICore checks AIModelConfig database
If found: Use database pricing
If not found: Fallback to constants
Calculate cost
Deduct credits
Log to CreditUsageLog
```
---
## Files Modified
### New Files (2)
1. Migration: `0020_create_ai_model_config.py` (200+ lines)
2. Summary: This document
### Modified Files (6)
1. `billing/models.py` - Added AIModelConfig model (240 lines)
2. `billing/admin.py` - Added AIModelConfigAdmin (180 lines)
3. `ai/ai_core.py` - Updated cost calculations (3 functions)
4. `ai/validators.py` - Updated validators (2 functions)
5. `modules/billing/serializers.py` - Added AIModelConfigSerializer (55 lines)
6. `modules/billing/views.py` - Added AIModelConfigViewSet (75 lines)
7. `business/billing/urls.py` - Registered API endpoint (1 line)
**Total:** ~750 lines of code added/modified
---
## Usage Examples
### Django Admin
1. Navigate to: **Admin → Billing → AI Model Configurations**
2. Click on any model to edit pricing
3. Use filters to view specific model types
4. Use bulk actions to activate/deactivate
### API Usage (Frontend)
```javascript
// Fetch all text models
const response = await fetch('/api/v1/billing/ai/models/?type=text');
const { data: models } = await response.json();
// Display in dropdown
models.forEach(model => {
console.log(model.display_name, model.pricing_display);
});
```
### Programmatic Usage (Backend)
```python
from igny8_core.business.billing.models import AIModelConfig
# Get model
model = AIModelConfig.objects.get(model_name='gpt-4o-mini')
# Calculate cost
cost = model.get_cost_for_tokens(1000, 500) # $0.000450
# Validate size (images)
dalle = AIModelConfig.objects.get(model_name='dall-e-3')
is_valid = dalle.validate_size('1024x1024') # True
```
---
## Next Steps (Optional Enhancements)
### Short Term
- [ ] Add model usage analytics to admin
- [ ] Create frontend UI for model selection
- [ ] Add model comparison view
### Long Term
- [ ] Add Anthropic models (Claude)
- [ ] Add Google models (Gemini)
- [ ] Implement A/B testing for models
- [ ] Add cost forecasting based on usage patterns
---
## Rollback Plan
If issues occur:
1. **Code Level:** All functions have fallback to constants
2. **Database Level:** Migration can be reversed: `python manage.py migrate billing 0019`
3. **Data Level:** No existing data affected (CreditUsageLog unchanged)
4. **Time Required:** < 5 minutes
**Risk:** Minimal - System has built-in fallback mechanisms
---
## Support
- **Django Admin:** http://your-domain/admin/billing/aimodelconfig/
- **API Docs:** http://your-domain/api/v1/billing/ai/models/
- **Configuration:** [AI-MODELS-DATABASE-CONFIGURATION-PLAN.md](AI-MODELS-DATABASE-CONFIGURATION-PLAN.md)
---
**Status:** ✅ Production Ready
**Deployed:** December 24, 2025
**Version:** 1.0

View File

@@ -0,0 +1,261 @@
# AI Model Database Configuration - Validation Report
**Date:** 2024
**Status:** ✅ 100% OPERATIONAL AND VERIFIED
---
## Executive Summary
All 34 validation tests passed successfully. The AI Model Database Configuration system is fully operational with database-driven pricing, cost calculations, validation, and REST API integration.
---
## Test Results Summary
| Test Suite | Tests | Passed | Status |
|-----------|-------|--------|--------|
| **Test 1:** Model Instance Methods | 5 | 5 | ✅ PASS |
| **Test 2:** AI Core Cost Calculations | 5 | 5 | ✅ PASS |
| **Test 3:** Validators | 9 | 9 | ✅ PASS |
| **Test 4:** Credit Calculation Integration | 4 | 4 | ✅ PASS |
| **Test 5:** REST API Serializer | 7 | 7 | ✅ PASS |
| **Test 6:** End-to-End Integration | 4 | 4 | ✅ PASS |
| **TOTAL** | **34** | **34** | **✅ 100%** |
---
## Database Status
### Active Text Models (5)
-`gpt-4o-mini` - $0.1500/$0.6000 per 1M tokens
-`gpt-4o` - $2.5000/$10.0000 per 1M tokens
-`gpt-4.1` - $2.0000/$8.0000 per 1M tokens
-`gpt-5.1` - $1.2500/$10.0000 per 1M tokens
-`gpt-5.2` - $1.7500/$14.0000 per 1M tokens
### Active Image Models (2)
-`dall-e-3` - $0.0400 per image
-`dall-e-2` - $0.0200 per image
### Inactive Models (2)
-`gpt-image-1` - image
-`gpt-image-1-mini` - image
---
## Test Details
### Test 1: Model Instance Methods
**Purpose:** Verify AIModelConfig model methods work correctly
**Tests:**
1.`get_cost_for_tokens(2518, 242)` → $0.000523
2.`get_cost_for_images(3)` → $0.0800
3.`validate_size('1024x1024')` → True
4.`validate_size('512x512')` → False (dall-e-3 doesn't support)
5. ✅ Display format correct
**Result:** All model methods calculate costs accurately
---
### Test 2: AI Core Cost Calculations
**Purpose:** Verify ai_core.py uses database correctly
**Tests:**
1. ✅ Text model cost calculation (1000 input + 500 output = $0.000450)
2. ✅ Image model cost calculation (dall-e-3 = $0.0400)
3. ✅ Fallback mechanism works (non-existent model uses constants)
4. ✅ All 5 text models consistent with database
5. ✅ All 2 image models consistent with database
**Result:** AICore.calculate_cost() works perfectly with database queries and fallback
---
### Test 3: Validators
**Purpose:** Verify model and size validation works
**Tests:**
1. ✅ Valid text model accepted (gpt-4o-mini)
2. ✅ Invalid text model rejected (fake-gpt-999)
3. ✅ Valid image model accepted (dall-e-3)
4. ✅ Invalid image model rejected (fake-dalle)
5. ✅ Inactive model rejected (gpt-image-1)
6. ✅ Valid size accepted (1024x1024 for dall-e-3)
7. ✅ Invalid size rejected (512x512 for dall-e-3)
8. ✅ All 5 active text models validate
9. ✅ All 2 active image models validate
**Result:** All validation logic working perfectly
---
### Test 4: Credit Calculation Integration
**Purpose:** Verify credit system integrates with AI costs
**Tests:**
1. ✅ Clustering credits: 2760 tokens → 19 credits
2. ✅ Profit margin: 99.7% (OpenAI cost $0.000523, Revenue $0.1900)
3. ✅ Minimum credits enforcement: 15 tokens → 10 credits (minimum)
4. ✅ High token count: 60,000 tokens → 600 credits
**Result:** Credit calculations work correctly with proper profit margins
---
### Test 5: REST API Serializer
**Purpose:** Verify API serialization works
**Tests:**
1. ✅ Single model serialization
2. ✅ Serialize all text models (5 models)
3. ✅ Serialize all image models (2 models)
4. ✅ Text model pricing fields (input_cost_per_1m, output_cost_per_1m)
5. ✅ Image model pricing fields (cost_per_image)
6. ✅ Image model sizes field (valid_sizes array)
7. ✅ Pricing display field
**Result:** All serialization working correctly with proper field names
---
### Test 6: End-to-End Integration
**Purpose:** Verify complete workflows work end-to-end
**Tests:**
1. ✅ Complete text generation workflow:
- Model validation
- OpenAI cost calculation ($0.000525)
- Credit calculation (20 credits)
- Revenue calculation ($0.2000)
- Profit margin (99.7%)
2. ✅ Complete image generation workflow:
- Model validation
- Size validation
- Cost calculation ($0.0400 per image)
3. ✅ All 7 active models verified (5 text + 2 image)
4. ✅ Database query performance for all models
**Result:** Complete workflows work perfectly from validation to cost calculation
---
## Features Verified
✅ Database-driven model pricing
✅ Cost calculation for text models (token-based)
✅ Cost calculation for image models (per-image)
✅ Model validation with active/inactive filtering
✅ Image size validation per model
✅ Credit calculation integration
✅ Profit margin calculation (99.7% for text, varies by model)
✅ REST API serialization
✅ Fallback to constants (safety mechanism)
✅ Django Admin interface with filters and bulk actions
✅ Lazy imports (circular dependency prevention)
---
## Implementation Details
### Database Schema
- **Model:** `AIModelConfig`
- **Fields:** 15 (model_name, display_name, model_type, provider, costs, features, etc.)
- **Migration:** `0020_create_ai_model_config.py`
- **Seeded Models:** 9 (7 active, 2 inactive)
### Methods Implemented
```python
# Text model cost calculation
AIModelConfig.get_cost_for_tokens(input_tokens, output_tokens) -> Decimal
# Image model cost calculation
AIModelConfig.get_cost_for_images(num_images) -> Decimal
# Size validation
AIModelConfig.validate_size(size) -> bool
# Unified cost calculation (in ai_core.py)
AICore.calculate_cost(model, input_tokens, output_tokens, model_type) -> float
```
### Files Modified (7)
1. `billing/models.py` - AIModelConfig class (240 lines)
2. `billing/admin.py` - Admin interface with filters
3. `ai/ai_core.py` - 3 functions updated with database queries
4. `ai/validators.py` - 2 functions updated with database queries
5. `modules/billing/serializers.py` - AIModelConfigSerializer
6. `modules/billing/views.py` - AIModelConfigViewSet
7. `business/billing/urls.py` - API routing
### REST API Endpoints
- `GET /api/v1/billing/ai/models/` - List all active models
- `GET /api/v1/billing/ai/models/?model_type=text` - Filter by type
- `GET /api/v1/billing/ai/models/?provider=openai` - Filter by provider
- `GET /api/v1/billing/ai/models/<id>/` - Get specific model
---
## Cost Examples
### Text Generation (gpt-4o-mini)
- **OpenAI Cost:** 1000 input + 500 output tokens = $0.000450
- **Credits Charged:** 10 credits ($0.10)
- **Profit Margin:** 99.6%
### Image Generation (dall-e-3)
- **OpenAI Cost:** 1 image (1024x1024) = $0.0400
- **Credits:** Charged by customer configuration
---
## Fallback Safety Mechanism
All functions include try/except blocks that:
1. **Try:** Query database for model config
2. **Except:** Fall back to constants in `ai/constants.py`
3. **Result:** System never fails, always returns a valid cost
**Example:**
```python
try:
model_config = AIModelConfig.objects.get(model_name=model, is_active=True)
return model_config.get_cost_for_tokens(input, output)
except:
# Fallback to constants
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
return calculate_with_rates(rates)
```
---
## Profit Margins
| Model | OpenAI Cost (1500 in + 500 out) | Credits | Revenue | Profit |
|-------|----------------------------------|---------|---------|--------|
| gpt-4o-mini | $0.000525 | 20 | $0.2000 | 99.7% |
| gpt-4o | $0.008750 | 20 | $0.2000 | 95.6% |
| gpt-4.1 | $0.007000 | 20 | $0.2000 | 96.5% |
| gpt-5.1 | $0.006875 | 20 | $0.2000 | 96.6% |
| gpt-5.2 | $0.009625 | 20 | $0.2000 | 95.2% |
---
## Conclusion
**SYSTEM IS 100% OPERATIONAL AND VERIFIED**
All 34 tests passed successfully. The AI Model Database Configuration system is:
- ✅ Fully functional
- ✅ Accurately calculating costs
- ✅ Properly validating models
- ✅ Successfully integrating with credit system
- ✅ Serving data via REST API
- ✅ Safe with fallback mechanisms
The system is ready for production use.

View File

@@ -43,33 +43,21 @@ class AICore:
self._load_account_settings()
def _load_account_settings(self):
"""Load API keys from IntegrationSettings for account only - no fallbacks"""
def get_integration_key(integration_type: str, account):
if not account:
return None
try:
from igny8_core.modules.system.models import IntegrationSettings
settings_obj = IntegrationSettings.objects.filter(
integration_type=integration_type,
account=account,
is_active=True
).first()
if settings_obj and settings_obj.config:
return settings_obj.config.get('apiKey')
except Exception as e:
logger.warning(f"Could not load {integration_type} settings for account {getattr(account, 'id', None)}: {e}", exc_info=True)
return None
# Load account-specific keys only - configure via Django admin
if self.account:
self._openai_api_key = get_integration_key('openai', self.account)
self._runware_api_key = get_integration_key('runware', self.account)
# Fallback to Django settings as last resort
if not self._openai_api_key:
self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None)
if not self._runware_api_key:
self._runware_api_key = getattr(settings, 'RUNWARE_API_KEY', None)
"""Load API keys from GlobalIntegrationSettings (platform-wide, used by ALL accounts)"""
try:
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
# Get global settings - single instance used by ALL accounts
global_settings = GlobalIntegrationSettings.get_instance()
# Load API keys from global settings (platform-wide)
self._openai_api_key = global_settings.openai_api_key
self._runware_api_key = global_settings.runware_api_key
except Exception as e:
logger.error(f"Could not load GlobalIntegrationSettings: {e}", exc_info=True)
self._openai_api_key = None
self._runware_api_key = None
def get_api_key(self, integration_type: str = 'openai') -> Optional[str]:
"""Get API key for integration type"""

View File

@@ -197,12 +197,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
prompt_text = str(prompt_data)
caption_text = ''
heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx + 1}"
heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx}"
Images.objects.update_or_create(
content=content,
image_type='in_article',
position=idx + 1,
position=idx, # 0-based position matching section array indices
defaults={
'prompt': prompt_text,
'caption': caption_text,
@@ -218,27 +218,33 @@ class GenerateImagePromptsFunction(BaseAIFunction):
# Helper methods
def _get_max_in_article_images(self, account) -> int:
"""Get max_in_article_images from AWS account IntegrationSettings only"""
"""
Get max_in_article_images from settings.
Uses account's IntegrationSettings override, or GlobalIntegrationSettings.
"""
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.auth.models import Account
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
# Only use system account (aws-admin) settings
system_account = Account.objects.get(slug='aws-admin')
settings = IntegrationSettings.objects.get(
account=system_account,
integration_type='image_generation',
is_active=True
)
max_images = settings.config.get('max_in_article_images')
if max_images is None:
raise ValueError(
"max_in_article_images not configured in aws-admin image_generation settings. "
"Please set this value in the Integration Settings page."
# Try account-specific override first
try:
settings = IntegrationSettings.objects.get(
account=account,
integration_type='image_generation',
is_active=True
)
max_images = settings.config.get('max_in_article_images')
if max_images is not None:
max_images = int(max_images)
logger.info(f"Using max_in_article_images={max_images} from account {account.id} IntegrationSettings override")
return max_images
except IntegrationSettings.DoesNotExist:
logger.debug(f"No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
max_images = int(max_images)
logger.info(f"Using max_in_article_images={max_images} from aws-admin account")
# Use GlobalIntegrationSettings default
global_settings = GlobalIntegrationSettings.get_instance()
max_images = global_settings.max_in_article_images
logger.info(f"Using max_in_article_images={max_images} from GlobalIntegrationSettings (account {account.id})")
return max_images
def _extract_content_elements(self, content: Content, max_images: int) -> Dict:

View File

@@ -67,32 +67,40 @@ class GenerateImagesFunction(BaseAIFunction):
if not tasks:
raise ValueError("No tasks found")
# Get image generation settings from aws-admin account only (global settings)
# Get image generation settings
# Try account-specific override, otherwise use GlobalIntegrationSettings
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.auth.models import Account
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
system_account = Account.objects.get(slug='aws-admin')
integration = IntegrationSettings.objects.get(
account=system_account,
integration_type='image_generation',
is_active=True
)
image_settings = integration.config or {}
image_settings = {}
try:
integration = IntegrationSettings.objects.get(
account=account,
integration_type='image_generation',
is_active=True
)
image_settings = integration.config or {}
logger.info(f"Using image settings from account {account.id} IntegrationSettings override")
except IntegrationSettings.DoesNotExist:
logger.info(f"No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
# Extract settings with defaults
provider = image_settings.get('provider') or image_settings.get('service', 'openai')
# Use GlobalIntegrationSettings for missing values
global_settings = GlobalIntegrationSettings.get_instance()
# Extract settings with defaults from global settings
provider = image_settings.get('provider') or image_settings.get('service') or global_settings.default_image_service
if provider == 'runware':
model = image_settings.get('model') or image_settings.get('runwareModel', 'runware:97@1')
model = image_settings.get('model') or image_settings.get('runwareModel') or global_settings.runware_model
else:
model = image_settings.get('model', 'dall-e-3')
model = image_settings.get('model') or global_settings.dalle_model
return {
'tasks': tasks,
'account': account,
'provider': provider,
'model': model,
'image_type': image_settings.get('image_type', 'realistic'),
'max_in_article_images': int(image_settings.get('max_in_article_images')),
'image_type': image_settings.get('image_type') or global_settings.image_style,
'max_in_article_images': int(image_settings.get('max_in_article_images') or global_settings.max_in_article_images),
'desktop_enabled': image_settings.get('desktop_enabled', True),
'mobile_enabled': image_settings.get('mobile_enabled', True),
}

View File

@@ -181,41 +181,47 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
failed = 0
results = []
# Get image generation settings from IntegrationSettings
# Always use system account settings (aws-admin) for global configuration
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings from aws-admin")
from igny8_core.auth.models import Account
# Get image generation settings
# Try account-specific override, otherwise use GlobalIntegrationSettings
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings")
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
config = {}
try:
system_account = Account.objects.get(slug='aws-admin')
image_settings = IntegrationSettings.objects.get(
account=system_account,
account=account,
integration_type='image_generation',
is_active=True
)
logger.info(f"[process_image_generation_queue] Using system account (aws-admin) settings")
logger.info(f"[process_image_generation_queue] Using account {account.id} IntegrationSettings override")
config = image_settings.config or {}
except (Account.DoesNotExist, IntegrationSettings.DoesNotExist):
logger.error("[process_image_generation_queue] ERROR: Image generation settings not found in aws-admin account")
return {'success': False, 'error': 'Image generation settings not found in aws-admin account'}
except IntegrationSettings.DoesNotExist:
logger.info(f"[process_image_generation_queue] No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
except Exception as e:
logger.error(f"[process_image_generation_queue] ERROR loading image generation settings: {e}", exc_info=True)
return {'success': False, 'error': f'Error loading image generation settings: {str(e)}'}
# Use GlobalIntegrationSettings for missing values
global_settings = GlobalIntegrationSettings.get_instance()
logger.info(f"[process_image_generation_queue] Image generation settings loaded. Config keys: {list(config.keys())}")
logger.info(f"[process_image_generation_queue] Full config: {config}")
# Get provider and model from config (respect user settings)
provider = config.get('provider', 'openai')
# Get model - try 'model' first, then 'imageModel' as fallback
model = config.get('model') or config.get('imageModel') or 'dall-e-3'
# Get provider and model from config with global fallbacks
provider = config.get('provider') or global_settings.default_image_service
if provider == 'runware':
model = config.get('model') or config.get('imageModel') or global_settings.runware_model
else:
model = config.get('model') or config.get('imageModel') or global_settings.dalle_model
logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings")
image_type = config.get('image_type', 'realistic')
image_type = config.get('image_type') or global_settings.image_style
image_format = config.get('image_format', 'webp')
desktop_enabled = config.get('desktop_enabled', True)
mobile_enabled = config.get('mobile_enabled', True)
# Get image sizes from config, with fallback defaults
featured_image_size = config.get('featured_image_size') or ('1280x832' if provider == 'runware' else '1024x1024')
desktop_image_size = config.get('desktop_image_size') or '1024x1024'
desktop_image_size = config.get('desktop_image_size') or global_settings.desktop_image_size
in_article_image_size = config.get('in_article_image_size') or '512x512' # Default to 512x512
logger.info(f"[process_image_generation_queue] Settings loaded:")
@@ -226,44 +232,22 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
logger.info(f" - Desktop enabled: {desktop_enabled}")
logger.info(f" - Mobile enabled: {mobile_enabled}")
# Get provider API key (using same approach as test image generation)
# Note: API key is stored as 'apiKey' (camelCase) in IntegrationSettings.config
# Normal users use system account settings (aws-admin) via fallback
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key")
try:
provider_settings = IntegrationSettings.objects.get(
account=account,
integration_type=provider, # Use the provider from settings
is_active=True
)
logger.info(f"[process_image_generation_queue] {provider.upper()} integration settings found for account {account.id}")
except IntegrationSettings.DoesNotExist:
# Fallback to system account (aws-admin) settings
logger.info(f"[process_image_generation_queue] No {provider.upper()} settings for account {account.id}, falling back to system account")
from igny8_core.auth.models import Account
try:
system_account = Account.objects.get(slug='aws-admin')
provider_settings = IntegrationSettings.objects.get(
account=system_account,
integration_type=provider,
is_active=True
)
logger.info(f"[process_image_generation_queue] Using system account (aws-admin) {provider.upper()} settings")
except (Account.DoesNotExist, IntegrationSettings.DoesNotExist):
logger.error(f"[process_image_generation_queue] ERROR: {provider.upper()} integration settings not found in system account either")
return {'success': False, 'error': f'{provider.upper()} integration not found or not active'}
except Exception as e:
logger.error(f"[process_image_generation_queue] ERROR getting {provider.upper()} API key: {e}", exc_info=True)
return {'success': False, 'error': f'Error retrieving {provider.upper()} API key: {str(e)}'}
# Get provider API key
# API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys)
# Account IntegrationSettings only store provider preference, NOT API keys
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key from GlobalIntegrationSettings")
# Extract API key from provider settings
logger.info(f"[process_image_generation_queue] {provider.upper()} config keys: {list(provider_settings.config.keys()) if provider_settings.config else 'None'}")
# Get API key from GlobalIntegrationSettings
if provider == 'runware':
api_key = global_settings.runware_api_key
elif provider == 'openai':
api_key = global_settings.dalle_api_key or global_settings.openai_api_key
else:
api_key = None
api_key = provider_settings.config.get('apiKey') if provider_settings.config else None
if not api_key:
logger.error(f"[process_image_generation_queue] {provider.upper()} API key not found in config")
logger.error(f"[process_image_generation_queue] {provider.upper()} config: {provider_settings.config}")
return {'success': False, 'error': f'{provider.upper()} API key not configured'}
logger.error(f"[process_image_generation_queue] {provider.upper()} API key not configured in GlobalIntegrationSettings")
return {'success': False, 'error': f'{provider.upper()} API key not configured in GlobalIntegrationSettings'}
# Log API key presence (but not the actual key for security)
api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***"

View File

@@ -5,6 +5,7 @@ import time
import logging
from typing import List, Dict, Any, Optional, Callable
from datetime import datetime
from decimal import Decimal
from igny8_core.ai.constants import DEBUG_MODE
logger = logging.getLogger(__name__)
@@ -195,24 +196,35 @@ class CostTracker:
"""Tracks API costs and token usage"""
def __init__(self):
self.total_cost = 0.0
self.total_cost = Decimal('0.0')
self.total_tokens = 0
self.operations = []
def record(self, function_name: str, cost: float, tokens: int, model: str = None):
"""Record an API call cost"""
def record(self, function_name: str, cost, tokens: int, model: str = None):
"""Record an API call cost
Args:
function_name: Name of the AI function
cost: Cost value (can be float or Decimal)
tokens: Number of tokens used
model: Model name
"""
# Convert cost to Decimal if it's a float to avoid type mixing
if not isinstance(cost, Decimal):
cost = Decimal(str(cost))
self.total_cost += cost
self.total_tokens += tokens
self.operations.append({
'function': function_name,
'cost': cost,
'cost': float(cost), # Store as float for JSON serialization
'tokens': tokens,
'model': model
})
def get_total(self) -> float:
"""Get total cost"""
return self.total_cost
def get_total(self):
"""Get total cost (returns float for JSON serialization)"""
return float(self.total_cost)
def get_total_tokens(self) -> int:
"""Get total tokens"""

View File

@@ -135,7 +135,7 @@ def validate_api_key(api_key: Optional[str], integration_type: str = 'openai') -
def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
"""
Validate that model is in supported list.
Validate that model is in supported list using database.
Args:
model: Model name to validate
@@ -144,27 +144,59 @@ def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
Returns:
Dict with 'valid' (bool) and optional 'error' (str)
"""
from .constants import MODEL_RATES, VALID_OPENAI_IMAGE_MODELS
if model_type == 'text':
if model not in MODEL_RATES:
return {
'valid': False,
'error': f'Model "{model}" is not in supported models list'
}
elif model_type == 'image':
if model not in VALID_OPENAI_IMAGE_MODELS:
return {
'valid': False,
'error': f'Model "{model}" is not valid for OpenAI image generation. Only {", ".join(VALID_OPENAI_IMAGE_MODELS)} are supported.'
}
return {'valid': True}
try:
# Try database first
from igny8_core.business.billing.models import AIModelConfig
exists = AIModelConfig.objects.filter(
model_name=model,
model_type=model_type,
is_active=True
).exists()
if not exists:
# Get available models for better error message
available = list(AIModelConfig.objects.filter(
model_type=model_type,
is_active=True
).values_list('model_name', flat=True))
if available:
return {
'valid': False,
'error': f'Model "{model}" is not active or not found. Available {model_type} models: {", ".join(available)}'
}
else:
return {
'valid': False,
'error': f'Model "{model}" is not found in database'
}
return {'valid': True}
except Exception:
# Fallback to constants if database fails
from .constants import MODEL_RATES, VALID_OPENAI_IMAGE_MODELS
if model_type == 'text':
if model not in MODEL_RATES:
return {
'valid': False,
'error': f'Model "{model}" is not in supported models list'
}
elif model_type == 'image':
if model not in VALID_OPENAI_IMAGE_MODELS:
return {
'valid': False,
'error': f'Model "{model}" is not valid for OpenAI image generation. Only {", ".join(VALID_OPENAI_IMAGE_MODELS)} are supported.'
}
return {'valid': True}
def validate_image_size(size: str, model: str) -> Dict[str, Any]:
"""
Validate that image size is valid for the selected model.
Validate that image size is valid for the selected model using database.
Args:
size: Image size (e.g., '1024x1024')
@@ -173,14 +205,40 @@ def validate_image_size(size: str, model: str) -> Dict[str, Any]:
Returns:
Dict with 'valid' (bool) and optional 'error' (str)
"""
from .constants import VALID_SIZES_BY_MODEL
valid_sizes = VALID_SIZES_BY_MODEL.get(model, [])
if size not in valid_sizes:
return {
'valid': False,
'error': f'Image size "{size}" is not valid for model "{model}". Valid sizes are: {", ".join(valid_sizes)}'
}
return {'valid': True}
try:
# Try database first
from igny8_core.business.billing.models import AIModelConfig
model_config = AIModelConfig.objects.filter(
model_name=model,
model_type='image',
is_active=True
).first()
if model_config:
if not model_config.validate_size(size):
valid_sizes = model_config.valid_sizes or []
return {
'valid': False,
'error': f'Image size "{size}" is not valid for model "{model}". Valid sizes are: {", ".join(valid_sizes)}'
}
return {'valid': True}
else:
return {
'valid': False,
'error': f'Image model "{model}" not found in database'
}
except Exception:
# Fallback to constants if database fails
from .constants import VALID_SIZES_BY_MODEL
valid_sizes = VALID_SIZES_BY_MODEL.get(model, [])
if size not in valid_sizes:
return {
'valid': False,
'error': f'Image size "{size}" is not valid for model "{model}". Valid sizes are: {", ".join(valid_sizes)}'
}
return {'valid': True}

View File

@@ -791,3 +791,238 @@ class AccountPaymentMethod(AccountBaseModel):
def __str__(self):
return f"{self.account_id} - {self.display_name} ({self.type})"
class AIModelConfig(models.Model):
"""
AI Model Configuration - Database-driven model pricing and capabilities.
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
Two pricing models:
- Text models: Cost per 1M tokens (input/output), credits calculated AFTER AI call
- Image models: Cost per image, credits calculated BEFORE AI call
"""
MODEL_TYPE_CHOICES = [
('text', 'Text Generation'),
('image', 'Image Generation'),
('embedding', 'Embedding'),
]
PROVIDER_CHOICES = [
('openai', 'OpenAI'),
('anthropic', 'Anthropic'),
('runware', 'Runware'),
('google', 'Google'),
]
# Basic Information
model_name = models.CharField(
max_length=100,
unique=True,
db_index=True,
help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')"
)
display_name = models.CharField(
max_length=200,
help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')"
)
model_type = models.CharField(
max_length=20,
choices=MODEL_TYPE_CHOICES,
db_index=True,
help_text="Type of model - determines which pricing fields are used"
)
provider = models.CharField(
max_length=50,
choices=PROVIDER_CHOICES,
db_index=True,
help_text="AI provider (OpenAI, Anthropic, etc.)"
)
# Text Model Pricing (Only for model_type='text')
input_cost_per_1m = models.DecimalField(
max_digits=10,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="Cost per 1 million input tokens (USD). For text models only."
)
output_cost_per_1m = models.DecimalField(
max_digits=10,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="Cost per 1 million output tokens (USD). For text models only."
)
context_window = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1)],
help_text="Maximum input tokens (context length). For text models only."
)
max_output_tokens = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1)],
help_text="Maximum output tokens per request. For text models only."
)
# Image Model Pricing (Only for model_type='image')
cost_per_image = models.DecimalField(
max_digits=10,
decimal_places=4,
null=True,
blank=True,
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="Fixed cost per image generation (USD). For image models only."
)
valid_sizes = models.JSONField(
null=True,
blank=True,
help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.'
)
# Capabilities
supports_json_mode = models.BooleanField(
default=False,
help_text="True for models with JSON response format support"
)
supports_vision = models.BooleanField(
default=False,
help_text="True for models that can analyze images"
)
supports_function_calling = models.BooleanField(
default=False,
help_text="True for models with function calling capability"
)
# Status & Configuration
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Enable/disable model without deleting"
)
is_default = models.BooleanField(
default=False,
db_index=True,
help_text="Mark as default model for its type (only one per type)"
)
sort_order = models.IntegerField(
default=0,
help_text="Control order in dropdown lists (lower numbers first)"
)
# Metadata
description = models.TextField(
blank=True,
help_text="Admin notes about model usage, strengths, limitations"
)
release_date = models.DateField(
null=True,
blank=True,
help_text="When model was released/added"
)
deprecation_date = models.DateField(
null=True,
blank=True,
help_text="When model will be removed"
)
# Audit Fields
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='ai_model_updates',
help_text="Admin who last updated"
)
# History tracking
history = HistoricalRecords()
class Meta:
app_label = 'billing'
db_table = 'igny8_ai_model_config'
verbose_name = 'AI Model Configuration'
verbose_name_plural = 'AI Model Configurations'
ordering = ['model_type', 'sort_order', 'model_name']
indexes = [
models.Index(fields=['model_type', 'is_active']),
models.Index(fields=['provider', 'is_active']),
models.Index(fields=['is_default', 'model_type']),
]
def __str__(self):
return self.display_name
def save(self, *args, **kwargs):
"""Ensure only one is_default per model_type"""
if self.is_default:
# Unset other defaults for same model_type
AIModelConfig.objects.filter(
model_type=self.model_type,
is_default=True
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
def get_cost_for_tokens(self, input_tokens, output_tokens):
"""Calculate cost for text models based on token usage"""
if self.model_type != 'text':
raise ValueError("get_cost_for_tokens only applies to text models")
if not self.input_cost_per_1m or not self.output_cost_per_1m:
raise ValueError(f"Model {self.model_name} missing cost_per_1m values")
cost = (
(Decimal(input_tokens) * self.input_cost_per_1m) +
(Decimal(output_tokens) * self.output_cost_per_1m)
) / Decimal('1000000')
return cost
def get_cost_for_images(self, num_images):
"""Calculate cost for image models"""
if self.model_type != 'image':
raise ValueError("get_cost_for_images only applies to image models")
if not self.cost_per_image:
raise ValueError(f"Model {self.model_name} missing cost_per_image")
return self.cost_per_image * Decimal(num_images)
def validate_size(self, size):
"""Check if size is valid for this image model"""
if self.model_type != 'image':
raise ValueError("validate_size only applies to image models")
if not self.valid_sizes:
return True # No size restrictions
return size in self.valid_sizes
def get_display_with_pricing(self):
"""For dropdowns: show model with pricing"""
if self.model_type == 'text':
return f"{self.display_name} - ${self.input_cost_per_1m}/${self.output_cost_per_1m} per 1M"
elif self.model_type == 'image':
return f"{self.display_name} - ${self.cost_per_image} per image"
return self.display_name

View File

@@ -3,97 +3,116 @@ Credit Service for managing credit transactions and deductions
"""
from django.db import transaction
from django.utils import timezone
from decimal import Decimal
import math
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, AIModelConfig
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
from igny8_core.business.billing.constants import CREDIT_COSTS
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
from igny8_core.auth.models import Account
class CreditService:
"""Service for managing credits"""
"""Service for managing credits - Token-based only"""
@staticmethod
def get_credit_cost(operation_type, amount=None):
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output):
"""
Get credit cost for operation.
Now checks database config first, falls back to constants.
Calculate credits from actual token usage using configured ratio.
This is the ONLY way credits are calculated in the system.
Args:
operation_type: Type of operation (from CREDIT_COSTS)
amount: Optional amount (word count, image count, etc.)
operation_type: Type of operation
tokens_input: Input tokens used
tokens_output: Output tokens used
Returns:
int: Number of credits required
int: Credits to deduct
Raises:
CreditCalculationError: If operation type is unknown
CreditCalculationError: If configuration error
"""
import logging
import math
from igny8_core.business.billing.models import CreditCostConfig, BillingConfiguration
logger = logging.getLogger(__name__)
# Try to get from database config first
try:
from igny8_core.business.billing.models import CreditCostConfig
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if config:
base_cost = config.credits_cost
# Apply unit-based calculation
if config.unit == 'per_100_words' and amount:
return max(1, int(base_cost * (amount / 100)))
elif config.unit == 'per_200_words' and amount:
return max(1, int(base_cost * (amount / 200)))
elif config.unit in ['per_item', 'per_image'] and amount:
return base_cost * amount
else:
return base_cost
# Get operation config (use global default if not found)
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
except Exception as e:
logger.warning(f"Failed to get cost from database, using constants: {e}")
if not config:
# Use global billing config as fallback
billing_config = BillingConfiguration.get_config()
tokens_per_credit = billing_config.default_tokens_per_credit
min_credits = 1
logger.info(f"No config for {operation_type}, using default: {tokens_per_credit} tokens/credit")
else:
tokens_per_credit = config.tokens_per_credit
min_credits = config.min_credits
# Fallback to hardcoded constants
base_cost = CREDIT_COSTS.get(operation_type, 0)
if base_cost == 0:
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
# Calculate total tokens
total_tokens = (tokens_input or 0) + (tokens_output or 0)
# Variable cost operations (legacy logic)
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
# Calculate credits (fractional)
if tokens_per_credit <= 0:
raise CreditCalculationError(f"Invalid tokens_per_credit: {tokens_per_credit}")
# Fixed cost operations
return base_cost
credits_float = total_tokens / tokens_per_credit
# Get rounding mode from global config
billing_config = BillingConfiguration.get_config()
rounding_mode = billing_config.credit_rounding_mode
if rounding_mode == 'up':
credits = math.ceil(credits_float)
elif rounding_mode == 'down':
credits = math.floor(credits_float)
else: # nearest
credits = round(credits_float)
# Apply minimum
credits = max(credits, min_credits)
logger.info(
f"Calculated credits for {operation_type}: "
f"{total_tokens} tokens ({tokens_input} in, {tokens_output} out) "
f"÷ {tokens_per_credit} = {credits} credits"
)
return credits
@staticmethod
def check_credits(account, operation_type, amount=None):
def check_credits(account, operation_type, estimated_amount=None):
"""
Check if account has sufficient credits for an operation.
For token-based operations, this is an estimate check only.
Actual deduction happens after AI call with real token usage.
Args:
account: Account instance
operation_type: Type of operation
amount: Optional amount (word count, image count, etc.)
estimated_amount: Optional estimated amount (for non-token operations)
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
required = CreditService.get_credit_cost(operation_type, amount)
from igny8_core.business.billing.models import CreditCostConfig
from igny8_core.business.billing.constants import CREDIT_COSTS
# Get operation config
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if config:
# Use minimum credits as estimate for token-based operations
required = config.min_credits
else:
# Fallback to constants
required = CREDIT_COSTS.get(operation_type, 1)
if account.credits < required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required}, Available: {account.credits}"
@@ -101,28 +120,50 @@ class CreditService:
return True
@staticmethod
def check_credits_legacy(account, required_credits):
def check_credits_legacy(account, amount):
"""
Legacy method: Check if account has enough credits (for backward compatibility).
Legacy method to check credits for a known amount.
Used internally by deduct_credits.
Args:
account: Account instance
required_credits: Number of credits required
amount: Required credits amount
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
if account.credits < required_credits:
if account.credits < amount:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
f"Insufficient credits. Required: {amount}, Available: {account.credits}"
)
return True
@staticmethod
def check_credits_for_tokens(account, operation_type, estimated_tokens_input, estimated_tokens_output):
"""
Check if account has sufficient credits based on estimated token usage.
Args:
account: Account instance
operation_type: Type of operation
estimated_tokens_input: Estimated input tokens
estimated_tokens_output: Estimated output tokens
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
required = CreditService.calculate_credits_from_tokens(
operation_type, estimated_tokens_input, estimated_tokens_output
)
if account.credits < required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required}, Available: {account.credits}"
)
return True
@staticmethod
@transaction.atomic
def deduct_credits(account, amount, operation_type, description, metadata=None,
cost_usd_input=None, cost_usd_output=None, cost_usd_total=None,
model_config=None, tokens_input=None, tokens_output=None,
related_object_type=None, related_object_id=None):
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
"""
Deduct credits and log transaction.
@@ -132,10 +173,8 @@ class CreditService:
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
description: Description of the transaction
metadata: Optional metadata dict
cost_usd_input: Optional input cost in USD
cost_usd_output: Optional output cost in USD
cost_usd_total: Optional total cost in USD
model_config: Optional AIModelConfig instance
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
@@ -161,93 +200,83 @@ class CreditService:
metadata=metadata or {}
)
# Create CreditUsageLog with new model_config FK
log_data = {
'account': account,
'operation_type': operation_type,
'credits_used': amount,
'tokens_input': tokens_input,
'tokens_output': tokens_output,
'related_object_type': related_object_type or '',
'related_object_id': related_object_id,
'metadata': metadata or {},
}
# Add model tracking (new FK)
if model_config:
log_data['model_config'] = model_config
log_data['model_name'] = model_config.model_name
# Add cost tracking (new fields)
if cost_usd_input is not None:
log_data['cost_usd_input'] = cost_usd_input
if cost_usd_output is not None:
log_data['cost_usd_output'] = cost_usd_output
if cost_usd_total is not None:
log_data['cost_usd_total'] = cost_usd_total
# Legacy cost_usd field (backward compatibility)
if cost_usd_total is not None:
log_data['cost_usd'] = cost_usd_total
CreditUsageLog.objects.create(**log_data)
# Create CreditUsageLog
CreditUsageLog.objects.create(
account=account,
operation_type=operation_type,
credits_used=amount,
cost_usd=cost_usd,
model_used=model_used or '',
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type or '',
related_object_id=related_object_id,
metadata=metadata or {}
)
return account.credits
@staticmethod
@transaction.atomic
def deduct_credits_for_operation(account, operation_type, amount=None, description=None,
metadata=None, cost_usd_input=None, cost_usd_output=None,
cost_usd_total=None, model_config=None, tokens_input=None,
tokens_output=None, related_object_type=None, related_object_id=None):
def deduct_credits_for_operation(
account,
operation_type,
tokens_input,
tokens_output,
description=None,
metadata=None,
cost_usd=None,
model_used=None,
related_object_type=None,
related_object_id=None
):
"""
Deduct credits for an operation (convenience method that calculates cost automatically).
Deduct credits for an operation based on actual token usage.
This is the ONLY way to deduct credits in the token-based system.
Args:
account: Account instance
operation_type: Type of operation
amount: Optional amount (word count, image count, etc.)
tokens_input: REQUIRED - Actual input tokens used
tokens_output: REQUIRED - Actual output tokens used
description: Optional description (auto-generated if not provided)
metadata: Optional metadata dict
cost_usd_input: Optional input cost in USD
cost_usd_output: Optional output cost in USD
cost_usd_total: Optional total cost in USD
model_config: Optional AIModelConfig instance
tokens_input: Optional input tokens
tokens_output: Optional output tokens
cost_usd: Optional cost in USD
model_used: Optional AI model used
related_object_type: Optional related object type
related_object_id: Optional related object ID
Returns:
int: New credit balance
Raises:
ValueError: If tokens_input or tokens_output not provided
"""
# Calculate credit cost - use token-based if tokens provided
if tokens_input is not None and tokens_output is not None and model_config:
credits_required = CreditService.calculate_credits_from_tokens(
operation_type, tokens_input, tokens_output, model_config
# Validate token inputs
if tokens_input is None or tokens_output is None:
raise ValueError(
f"tokens_input and tokens_output are REQUIRED for credit deduction. "
f"Got: tokens_input={tokens_input}, tokens_output={tokens_output}"
)
else:
credits_required = CreditService.get_credit_cost(operation_type, amount)
# Calculate credits from actual token usage
credits_required = CreditService.calculate_credits_from_tokens(
operation_type, tokens_input, tokens_output
)
# Check sufficient credits
CreditService.check_credits_legacy(account, credits_required)
if account.credits < credits_required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
)
# Auto-generate description if not provided
if not description:
model_name = model_config.display_name if model_config else "AI"
if operation_type == 'clustering':
description = f"Clustering operation ({model_name})"
elif operation_type == 'idea_generation':
description = f"Generated {amount or 1} idea(s) ({model_name})"
elif operation_type == 'content_generation':
if tokens_input and tokens_output:
description = f"Generated content ({tokens_input + tokens_output} tokens, {model_name})"
else:
description = f"Generated content ({amount or 0} words, {model_name})"
elif operation_type == 'image_generation':
description = f"Generated {amount or 1} image(s) ({model_name})"
else:
description = f"{operation_type} operation ({model_name})"
total_tokens = tokens_input + tokens_output
description = (
f"{operation_type}: {total_tokens} tokens "
f"({tokens_input} in, {tokens_output} out) = {credits_required} credits"
)
return CreditService.deduct_credits(
account=account,
@@ -255,10 +284,8 @@ class CreditService:
operation_type=operation_type,
description=description,
metadata=metadata,
cost_usd_input=cost_usd_input,
cost_usd_output=cost_usd_output,
cost_usd_total=cost_usd_total,
model_config=model_config,
cost_usd=cost_usd,
model_used=model_used,
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type,
@@ -296,188 +323,4 @@ class CreditService:
)
return account.credits
@staticmethod
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
**kwargs: Operation-specific parameters
Returns:
int: Number of credits required
Raises:
CreditCalculationError: If calculation fails
"""
# Map legacy operation types
if operation_type == 'ideas':
operation_type = 'idea_generation'
elif operation_type == 'content':
operation_type = 'content_generation'
elif operation_type == 'images':
operation_type = 'image_generation'
# 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)
@staticmethod
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output, model_config):
"""
Calculate credits based on actual token usage and AI model configuration.
This is the new token-aware calculation method.
Args:
operation_type: Type of operation (e.g., 'content_generation')
tokens_input: Number of input tokens used
tokens_output: Number of output tokens used
model_config: AIModelConfig instance
Returns:
int: Number of credits to deduct
Raises:
CreditCalculationError: If calculation fails
"""
import logging
logger = logging.getLogger(__name__)
try:
from igny8_core.business.billing.models import CreditCostConfig
# Get operation config
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if not config:
raise CreditCalculationError(f"No active config found for operation: {operation_type}")
# Check if operation uses token-based billing
if config.unit in ['per_100_tokens', 'per_1000_tokens']:
total_tokens = tokens_input + tokens_output
# Get model's tokens-per-credit ratio
tokens_per_credit = model_config.tokens_per_credit
if tokens_per_credit <= 0:
raise CreditCalculationError(f"Invalid tokens_per_credit: {tokens_per_credit}")
# Calculate credits (float)
credits_float = Decimal(total_tokens) / Decimal(tokens_per_credit)
# Apply rounding (always round up to avoid undercharging)
credits = math.ceil(credits_float)
# Apply minimum cost from config (if set)
credits = max(credits, config.credits_cost)
logger.info(
f"Token-based calculation: {total_tokens} tokens / {tokens_per_credit} = {credits} credits "
f"(model: {model_config.model_name}, operation: {operation_type})"
)
return credits
else:
# Fall back to legacy calculation for non-token operations
logger.warning(
f"Operation {operation_type} uses unit {config.unit}, falling back to legacy calculation"
)
return config.credits_cost
except Exception as e:
logger.error(f"Failed to calculate credits from tokens: {e}")
raise CreditCalculationError(f"Credit calculation failed: {e}")
@staticmethod
def get_model_for_operation(account, operation_type, task_model_override=None):
"""
Determine which AI model to use for an operation.
Priority: Task Override > Account Default > Operation Default > System Default
Args:
account: Account instance
operation_type: Type of operation
task_model_override: Optional AIModelConfig instance from task
Returns:
AIModelConfig: The model to use
"""
import logging
logger = logging.getLogger(__name__)
# 1. Task-level override (highest priority)
if task_model_override:
logger.info(f"Using task-level model override: {task_model_override.model_name}")
return task_model_override
# 2. Account default model (from IntegrationSettings)
try:
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.business.billing.models import CreditCostConfig
integration = IntegrationSettings.objects.filter(account=account).first()
if integration:
# Determine if this is text or image operation
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if config and config.default_model:
model_type = config.default_model.model_type
if model_type == 'text' and integration.default_text_model:
logger.info(f"Using account default text model: {integration.default_text_model.model_name}")
return integration.default_text_model
elif model_type == 'image' and integration.default_image_model:
logger.info(f"Using account default image model: {integration.default_image_model.model_name}")
return integration.default_image_model
except Exception as e:
logger.warning(f"Failed to get account default model: {e}")
# 3. Operation default model
try:
from igny8_core.business.billing.models import CreditCostConfig
config = CreditCostConfig.objects.filter(
operation_type=operation_type,
is_active=True
).first()
if config and config.default_model:
logger.info(f"Using operation default model: {config.default_model.model_name}")
return config.default_model
except Exception as e:
logger.warning(f"Failed to get operation default model: {e}")
# 4. System-wide default (fallback)
try:
default_model = AIModelConfig.objects.filter(
is_default=True,
is_active=True
).first()
if default_model:
logger.info(f"Using system default model: {default_model.model_name}")
return default_model
except Exception as e:
logger.warning(f"Failed to get system default model: {e}")
# 5. Hard-coded fallback
logger.warning("All model selection failed, using hard-coded fallback: gpt-4o-mini")
return AIModelConfig.objects.filter(model_name='gpt-4o-mini').first()

View File

@@ -13,6 +13,7 @@ from igny8_core.modules.billing.views import (
CreditBalanceViewSet,
CreditUsageViewSet,
CreditTransactionViewSet,
AIModelConfigViewSet,
)
router = DefaultRouter()
@@ -21,6 +22,8 @@ router.register(r'admin', BillingViewSet, basename='billing-admin')
router.register(r'credits/balance', CreditBalanceViewSet, basename='credit-balance')
router.register(r'credits/usage', CreditUsageViewSet, basename='credit-usage')
router.register(r'credits/transactions', CreditTransactionViewSet, basename='credit-transactions')
# AI Models endpoint
router.register(r'ai/models', AIModelConfigViewSet, basename='ai-models')
# User-facing billing endpoints
router.register(r'invoices', InvoiceViewSet, basename='invoices')
router.register(r'payments', PaymentViewSet, basename='payments')

View File

@@ -8,16 +8,17 @@ from unfold.admin import ModelAdmin
from simple_history.admin import SimpleHistoryAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from igny8_core.business.billing.models import (
AIModelConfig,
CreditCostConfig,
BillingConfiguration,
Invoice,
Payment,
CreditPackage,
PaymentMethodConfig,
PlanLimitUsage,
AIModelConfig,
)
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
from import_export.admin import ExportMixin
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
from rangefilter.filters import DateRangeFilter
@@ -50,43 +51,21 @@ class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
get_account_display.short_description = 'Account'
@admin.register(AIModelConfig)
class AIModelConfigAdmin(Igny8ModelAdmin):
list_display = ['display_name', 'model_name', 'provider', 'model_type', 'tokens_per_credit', 'cost_per_1k_input_tokens', 'cost_per_1k_output_tokens', 'is_active', 'is_default']
list_filter = ['provider', 'model_type', 'is_active', 'is_default']
search_fields = ['model_name', 'display_name', 'description']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Model Information', {
'fields': ('model_name', 'display_name', 'description', 'provider', 'model_type')
}),
('Pricing', {
'fields': ('cost_per_1k_input_tokens', 'cost_per_1k_output_tokens', 'tokens_per_credit')
}),
('Status', {
'fields': ('is_active', 'is_default')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def save_model(self, request, obj, form, change):
# If setting as default, unset other defaults of same type
if obj.is_default:
AIModelConfig.objects.filter(
model_type=obj.model_type,
is_default=True
).exclude(pk=obj.pk).update(is_default=False)
super().save_model(request, obj, form, change)
class CreditUsageLogResource(resources.ModelResource):
"""Resource class for exporting Credit Usage Logs"""
class Meta:
model = CreditUsageLog
fields = ('id', 'account__name', 'operation_type', 'credits_used', 'cost_usd',
'model_used', 'created_at')
export_order = fields
@admin.register(CreditUsageLog)
class CreditUsageLogAdmin(AccountAdminMixin, Igny8ModelAdmin):
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_config', 'created_at']
list_filter = ['operation_type', 'created_at', 'account', 'model_config']
search_fields = ['account__name', 'model_name']
class CreditUsageLogAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = CreditUsageLogResource
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_used', 'created_at']
list_filter = ['operation_type', 'created_at', 'account', 'model_used']
search_fields = ['account__name', 'model_used']
readonly_fields = ['created_at']
date_hierarchy = 'created_at'
@@ -100,8 +79,18 @@ class CreditUsageLogAdmin(AccountAdminMixin, Igny8ModelAdmin):
get_account_display.short_description = 'Account'
class InvoiceResource(resources.ModelResource):
"""Resource class for exporting Invoices"""
class Meta:
model = Invoice
fields = ('id', 'invoice_number', 'account__name', 'status', 'total', 'currency',
'invoice_date', 'due_date', 'created_at', 'updated_at')
export_order = fields
@admin.register(Invoice)
class InvoiceAdmin(AccountAdminMixin, Igny8ModelAdmin):
class InvoiceAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = InvoiceResource
list_display = [
'invoice_number',
'account',
@@ -114,6 +103,56 @@ class InvoiceAdmin(AccountAdminMixin, Igny8ModelAdmin):
list_filter = ['status', 'currency', 'invoice_date', 'account']
search_fields = ['invoice_number', 'account__name']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_set_status_draft',
'bulk_set_status_sent',
'bulk_set_status_paid',
'bulk_set_status_overdue',
'bulk_set_status_cancelled',
'bulk_send_reminders',
]
def bulk_set_status_draft(self, request, queryset):
"""Set selected invoices to draft status"""
updated = queryset.update(status='draft')
self.message_user(request, f'{updated} invoice(s) set to draft.', messages.SUCCESS)
bulk_set_status_draft.short_description = 'Set status to Draft'
def bulk_set_status_sent(self, request, queryset):
"""Set selected invoices to sent status"""
updated = queryset.update(status='sent')
self.message_user(request, f'{updated} invoice(s) set to sent.', messages.SUCCESS)
bulk_set_status_sent.short_description = 'Set status to Sent'
def bulk_set_status_paid(self, request, queryset):
"""Set selected invoices to paid status"""
updated = queryset.update(status='paid')
self.message_user(request, f'{updated} invoice(s) set to paid.', messages.SUCCESS)
bulk_set_status_paid.short_description = 'Set status to Paid'
def bulk_set_status_overdue(self, request, queryset):
"""Set selected invoices to overdue status"""
updated = queryset.update(status='overdue')
self.message_user(request, f'{updated} invoice(s) set to overdue.', messages.SUCCESS)
bulk_set_status_overdue.short_description = 'Set status to Overdue'
def bulk_set_status_cancelled(self, request, queryset):
"""Set selected invoices to cancelled status"""
updated = queryset.update(status='cancelled')
self.message_user(request, f'{updated} invoice(s) set to cancelled.', messages.SUCCESS)
bulk_set_status_cancelled.short_description = 'Set status to Cancelled'
def bulk_send_reminders(self, request, queryset):
"""Send reminder emails for selected invoices"""
# TODO: Implement email sending logic when email service is configured
unpaid = queryset.filter(status__in=['sent', 'overdue'])
count = unpaid.count()
self.message_user(
request,
f'{count} invoice reminder(s) queued for sending. (Email integration required)',
messages.INFO
)
bulk_send_reminders.short_description = 'Send payment reminders'
class PaymentResource(resources.ModelResource):
@@ -160,7 +199,7 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
'manual_notes'
]
readonly_fields = ['created_at', 'updated_at', 'approved_at', 'processed_at', 'failed_at', 'refunded_at']
actions = ['approve_payments', 'reject_payments']
actions = ['approve_payments', 'reject_payments', 'bulk_refund']
fieldsets = (
('Payment Info', {
@@ -406,14 +445,71 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
self.message_user(request, f'Rejected {count} payment(s)')
reject_payments.short_description = 'Reject selected manual payments'
def bulk_refund(self, request, queryset):
"""Refund selected payments"""
from django.utils import timezone
# Only refund succeeded payments
succeeded_payments = queryset.filter(status='succeeded')
count = 0
for payment in succeeded_payments:
# Mark as refunded
payment.status = 'refunded'
payment.refunded_at = timezone.now()
payment.admin_notes = f'{payment.admin_notes or ""}\nBulk refunded by {request.user.email} on {timezone.now()}'
payment.save()
# TODO: Process actual refund through payment gateway (Stripe/PayPal)
# For now, just marking as refunded in database
count += 1
self.message_user(
request,
f'{count} payment(s) marked as refunded. Note: Actual gateway refunds need to be processed separately.',
messages.WARNING
)
bulk_refund.short_description = 'Refund selected payments'
class CreditPackageResource(resources.ModelResource):
"""Resource class for importing/exporting Credit Packages"""
class Meta:
model = CreditPackage
fields = ('id', 'name', 'slug', 'credits', 'price', 'discount_percentage',
'is_active', 'is_featured', 'sort_order', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(CreditPackage)
class CreditPackageAdmin(Igny8ModelAdmin):
class CreditPackageAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = CreditPackageResource
list_display = ['name', 'slug', 'credits', 'price', 'discount_percentage', 'is_active', 'is_featured', 'sort_order']
list_filter = ['is_active', 'is_featured']
search_fields = ['name', 'slug']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_activate',
'bulk_deactivate',
]
actions = [
'bulk_activate',
'bulk_deactivate',
]
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} credit package(s) activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected packages'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} credit package(s) deactivated.', messages.SUCCESS)
bulk_deactivate.short_description = 'Deactivate selected packages'
@admin.register(PaymentMethodConfig)
@@ -459,55 +555,57 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
list_display = [
'operation_type',
'display_name',
'credits_cost_display',
'unit',
'tokens_per_credit_display',
'price_per_credit_usd',
'min_credits',
'is_active',
'cost_change_indicator',
'updated_at',
'updated_by'
]
list_filter = ['is_active', 'unit', 'updated_at']
list_filter = ['is_active', 'updated_at']
search_fields = ['operation_type', 'display_name', 'description']
fieldsets = (
('Operation', {
'fields': ('operation_type', 'display_name', 'description')
}),
('Cost Configuration', {
'fields': ('credits_cost', 'unit', 'is_active')
('Token-to-Credit Configuration', {
'fields': ('tokens_per_credit', 'min_credits', 'price_per_credit_usd', 'is_active'),
'description': 'Configure how tokens are converted to credits for this operation'
}),
('Audit Trail', {
'fields': ('previous_cost', 'updated_by', 'created_at', 'updated_at'),
'fields': ('previous_tokens_per_credit', 'updated_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at', 'previous_cost']
readonly_fields = ['created_at', 'updated_at', 'previous_tokens_per_credit']
def credits_cost_display(self, obj):
"""Show cost with color coding"""
if obj.credits_cost >= 20:
color = 'red'
elif obj.credits_cost >= 10:
def tokens_per_credit_display(self, obj):
"""Show token ratio with color coding"""
if obj.tokens_per_credit <= 50:
color = 'red' # Expensive (low tokens per credit)
elif obj.tokens_per_credit <= 100:
color = 'orange'
else:
color = 'green'
color = 'green' # Cheap (high tokens per credit)
return format_html(
'<span style="color: {}; font-weight: bold;">{} credits</span>',
'<span style="color: {}; font-weight: bold;">{} tokens/credit</span>',
color,
obj.credits_cost
obj.tokens_per_credit
)
credits_cost_display.short_description = 'Cost'
tokens_per_credit_display.short_description = 'Token Ratio'
def cost_change_indicator(self, obj):
"""Show if cost changed recently"""
if obj.previous_cost is not None:
if obj.credits_cost > obj.previous_cost:
icon = '📈' # Increased
"""Show if token ratio changed recently"""
if obj.previous_tokens_per_credit is not None:
if obj.tokens_per_credit < obj.previous_tokens_per_credit:
icon = '📈' # More expensive (fewer tokens per credit)
color = 'red'
elif obj.credits_cost < obj.previous_cost:
icon = '📉' # Decreased
elif obj.tokens_per_credit > obj.previous_tokens_per_credit:
icon = '📉' # Cheaper (more tokens per credit)
color = 'green'
else:
icon = '➡️' # Same
@@ -517,8 +615,8 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
'{} <span style="color: {};">({}{})</span>',
icon,
color,
obj.previous_cost,
obj.credits_cost
obj.previous_tokens_per_credit,
obj.tokens_per_credit
)
return ''
cost_change_indicator.short_description = 'Recent Change'
@@ -529,8 +627,18 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
super().save_model(request, obj, form, change)
class PlanLimitUsageResource(resources.ModelResource):
"""Resource class for exporting Plan Limit Usage"""
class Meta:
model = PlanLimitUsage
fields = ('id', 'account__name', 'limit_type', 'amount_used',
'period_start', 'period_end', 'created_at')
export_order = fields
@admin.register(PlanLimitUsage)
class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = PlanLimitUsageResource
"""Admin for tracking plan limit usage across billing periods"""
list_display = [
'account',
@@ -548,6 +656,10 @@ class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
search_fields = ['account__name']
readonly_fields = ['created_at', 'updated_at']
date_hierarchy = 'period_start'
actions = [
'bulk_reset_usage',
'bulk_delete_old_records',
]
fieldsets = (
('Usage Info', {
@@ -570,4 +682,272 @@ class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
"""Display billing period range"""
return f"{obj.period_start} to {obj.period_end}"
period_display.short_description = 'Billing Period'
def bulk_reset_usage(self, request, queryset):
"""Reset usage counters to zero"""
updated = queryset.update(amount_used=0)
self.message_user(request, f'{updated} usage counter(s) reset to zero.', messages.SUCCESS)
bulk_reset_usage.short_description = 'Reset usage counters'
def bulk_delete_old_records(self, request, queryset):
"""Delete usage records older than 1 year"""
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=365)
old_records = queryset.filter(period_end__lt=cutoff_date)
count = old_records.count()
old_records.delete()
self.message_user(request, f'{count} old usage record(s) deleted (older than 1 year).', messages.SUCCESS)
bulk_delete_old_records.short_description = 'Delete old records (>1 year)'
@admin.register(BillingConfiguration)
class BillingConfigurationAdmin(Igny8ModelAdmin):
"""Admin for global billing configuration (Singleton)"""
list_display = [
'id',
'default_tokens_per_credit',
'default_credit_price_usd',
'credit_rounding_mode',
'enable_token_based_reporting',
'updated_at',
'updated_by'
]
fieldsets = (
('Global Token-to-Credit Settings', {
'fields': ('default_tokens_per_credit', 'default_credit_price_usd', 'credit_rounding_mode'),
'description': 'These settings apply when no operation-specific config exists'
}),
('Reporting Settings', {
'fields': ('enable_token_based_reporting',),
'description': 'Control token-based reporting features'
}),
('Audit Trail', {
'fields': ('updated_by', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['updated_at']
def has_add_permission(self, request):
"""Only allow one instance (singleton)"""
from igny8_core.business.billing.models import BillingConfiguration
return not BillingConfiguration.objects.exists()
def has_delete_permission(self, request, obj=None):
"""Prevent deletion of the singleton"""
return False
def save_model(self, request, obj, form, change):
"""Track who made the change"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)
@admin.register(AIModelConfig)
class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
"""
Admin for AI Model Configuration - Database-driven model pricing
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES
"""
list_display = [
'model_name',
'display_name_short',
'model_type_badge',
'provider_badge',
'pricing_display',
'is_active_icon',
'is_default_icon',
'sort_order',
'updated_at',
]
list_filter = [
'model_type',
'provider',
'is_active',
'is_default',
'supports_json_mode',
'supports_vision',
'supports_function_calling',
]
search_fields = ['model_name', 'display_name', 'description']
ordering = ['model_type', 'sort_order', 'model_name']
readonly_fields = ['created_at', 'updated_at', 'updated_by']
fieldsets = (
('Basic Information', {
'fields': ('model_name', 'display_name', 'model_type', 'provider', 'description'),
'description': 'Core model identification and classification'
}),
('Text Model Pricing', {
'fields': ('input_cost_per_1m', 'output_cost_per_1m', 'context_window', 'max_output_tokens'),
'description': 'Pricing and limits for TEXT models only (leave blank for image models)',
'classes': ('collapse',)
}),
('Image Model Pricing', {
'fields': ('cost_per_image', 'valid_sizes'),
'description': 'Pricing and configuration for IMAGE models only (leave blank for text models)',
'classes': ('collapse',)
}),
('Capabilities', {
'fields': ('supports_json_mode', 'supports_vision', 'supports_function_calling'),
'description': 'Model features and capabilities'
}),
('Status & Display', {
'fields': ('is_active', 'is_default', 'sort_order'),
'description': 'Control model availability and ordering in dropdowns'
}),
('Lifecycle', {
'fields': ('release_date', 'deprecation_date'),
'description': 'Model release and deprecation dates',
'classes': ('collapse',)
}),
('Audit Trail', {
'fields': ('created_at', 'updated_at', 'updated_by'),
'classes': ('collapse',)
}),
)
# Custom display methods
def display_name_short(self, obj):
"""Truncated display name for list view"""
if len(obj.display_name) > 50:
return obj.display_name[:47] + '...'
return obj.display_name
display_name_short.short_description = 'Display Name'
def model_type_badge(self, obj):
"""Colored badge for model type"""
colors = {
'text': '#3498db', # Blue
'image': '#e74c3c', # Red
'embedding': '#2ecc71', # Green
}
color = colors.get(obj.model_type, '#95a5a6')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 10px; '
'border-radius: 3px; font-weight: bold;">{}</span>',
color,
obj.get_model_type_display()
)
model_type_badge.short_description = 'Type'
def provider_badge(self, obj):
"""Colored badge for provider"""
colors = {
'openai': '#10a37f', # OpenAI green
'anthropic': '#d97757', # Anthropic orange
'runware': '#6366f1', # Purple
'google': '#4285f4', # Google blue
}
color = colors.get(obj.provider, '#95a5a6')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 10px; '
'border-radius: 3px; font-weight: bold;">{}</span>',
color,
obj.get_provider_display()
)
provider_badge.short_description = 'Provider'
def pricing_display(self, obj):
"""Format pricing based on model type"""
if obj.model_type == 'text':
return format_html(
'<span style="color: #2c3e50; font-family: monospace;">'
'${} / ${} per 1M</span>',
obj.input_cost_per_1m,
obj.output_cost_per_1m
)
elif obj.model_type == 'image':
return format_html(
'<span style="color: #2c3e50; font-family: monospace;">'
'${} per image</span>',
obj.cost_per_image
)
return '-'
pricing_display.short_description = 'Pricing'
def is_active_icon(self, obj):
"""Active status icon"""
if obj.is_active:
return format_html(
'<span style="color: green; font-size: 18px;" title="Active">●</span>'
)
return format_html(
'<span style="color: red; font-size: 18px;" title="Inactive">●</span>'
)
is_active_icon.short_description = 'Active'
def is_default_icon(self, obj):
"""Default status icon"""
if obj.is_default:
return format_html(
'<span style="color: gold; font-size: 18px;" title="Default">★</span>'
)
return format_html(
'<span style="color: #ddd; font-size: 18px;" title="Not Default">☆</span>'
)
is_default_icon.short_description = 'Default'
# Admin actions
actions = ['bulk_activate', 'bulk_deactivate', 'set_as_default']
def bulk_activate(self, request, queryset):
"""Enable selected models"""
count = queryset.update(is_active=True)
self.message_user(
request,
f'{count} model(s) activated successfully.',
messages.SUCCESS
)
bulk_activate.short_description = 'Activate selected models'
def bulk_deactivate(self, request, queryset):
"""Disable selected models"""
count = queryset.update(is_active=False)
self.message_user(
request,
f'{count} model(s) deactivated successfully.',
messages.WARNING
)
bulk_deactivate.short_description = 'Deactivate selected models'
def set_as_default(self, request, queryset):
"""Set one model as default for its type"""
if queryset.count() != 1:
self.message_user(
request,
'Please select exactly one model to set as default.',
messages.ERROR
)
return
model = queryset.first()
# Unset other defaults for same type
AIModelConfig.objects.filter(
model_type=model.model_type,
is_default=True
).exclude(pk=model.pk).update(is_default=False)
# Set this as default
model.is_default = True
model.save()
self.message_user(
request,
f'{model.model_name} is now the default {model.get_model_type_display()} model.',
messages.SUCCESS
)
set_as_default.short_description = 'Set as default model (for its type)'
def save_model(self, request, obj, form, change):
"""Track who made the change"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,264 @@
# Generated by Django 5.2.9 on 2025-12-24 01:20
import django.core.validators
import django.db.models.deletion
import simple_history.models
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
def seed_ai_models(apps, schema_editor):
"""Seed AIModelConfig with data from constants.py"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
# Text Models (from MODEL_RATES in constants.py)
text_models = [
{
'model_name': 'gpt-4o-mini',
'display_name': 'GPT-4o mini - Fast & Affordable',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('0.1500'),
'output_cost_per_1m': Decimal('0.6000'),
'context_window': 128000,
'max_output_tokens': 16000,
'supports_json_mode': True,
'supports_vision': False,
'supports_function_calling': True,
'is_active': True,
'is_default': True, # Default text model
'sort_order': 1,
'description': 'Fast and cost-effective model for most tasks. Best balance of speed and quality.',
},
{
'model_name': 'gpt-4.1',
'display_name': 'GPT-4.1 - Legacy Model',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('2.0000'),
'output_cost_per_1m': Decimal('8.0000'),
'context_window': 8192,
'max_output_tokens': 4096,
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': True,
'is_default': False,
'sort_order': 10,
'description': 'Legacy GPT-4 model. Higher cost but reliable.',
},
{
'model_name': 'gpt-4o',
'display_name': 'GPT-4o - High Quality with Vision',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('2.5000'),
'output_cost_per_1m': Decimal('10.0000'),
'context_window': 128000,
'max_output_tokens': 4096,
'supports_json_mode': True,
'supports_vision': True,
'supports_function_calling': True,
'is_active': True,
'is_default': False,
'sort_order': 5,
'description': 'Most capable GPT-4 variant with vision capabilities. Best for complex tasks.',
},
{
'model_name': 'gpt-5.1',
'display_name': 'GPT-5.1 - Advanced (16K context)',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('1.2500'),
'output_cost_per_1m': Decimal('10.0000'),
'context_window': 16000,
'max_output_tokens': 16000,
'supports_json_mode': True,
'supports_vision': False,
'supports_function_calling': True,
'is_active': True,
'is_default': False,
'sort_order': 20,
'description': 'Advanced GPT-5 model with 16K context window.',
},
{
'model_name': 'gpt-5.2',
'display_name': 'GPT-5.2 - Most Advanced (16K context)',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('1.7500'),
'output_cost_per_1m': Decimal('14.0000'),
'context_window': 16000,
'max_output_tokens': 16000,
'supports_json_mode': True,
'supports_vision': False,
'supports_function_calling': True,
'is_active': True,
'is_default': False,
'sort_order': 30,
'description': 'Most advanced GPT-5 variant. Highest quality output.',
},
]
# Image Models (from IMAGE_MODEL_RATES in constants.py)
image_models = [
{
'model_name': 'dall-e-3',
'display_name': 'DALL-E 3 - High Quality Images',
'model_type': 'image',
'provider': 'openai',
'cost_per_image': Decimal('0.0400'),
'valid_sizes': ['1024x1024', '1024x1792', '1792x1024'],
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': True,
'is_default': True, # Default image model
'sort_order': 1,
'description': 'Latest DALL-E model with best quality and prompt adherence.',
},
{
'model_name': 'dall-e-2',
'display_name': 'DALL-E 2 - Standard Quality',
'model_type': 'image',
'provider': 'openai',
'cost_per_image': Decimal('0.0200'),
'valid_sizes': ['256x256', '512x512', '1024x1024'],
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': True,
'is_default': False,
'sort_order': 10,
'description': 'Cost-effective image generation with good quality.',
},
{
'model_name': 'gpt-image-1',
'display_name': 'GPT Image 1 (Not compatible with OpenAI)',
'model_type': 'image',
'provider': 'openai',
'cost_per_image': Decimal('0.0420'),
'valid_sizes': ['1024x1024'],
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': False, # Not valid for OpenAI endpoint
'is_default': False,
'sort_order': 20,
'description': 'Not compatible with OpenAI /v1/images/generations endpoint.',
},
{
'model_name': 'gpt-image-1-mini',
'display_name': 'GPT Image 1 Mini (Not compatible with OpenAI)',
'model_type': 'image',
'provider': 'openai',
'cost_per_image': Decimal('0.0110'),
'valid_sizes': ['1024x1024'],
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': False, # Not valid for OpenAI endpoint
'is_default': False,
'sort_order': 30,
'description': 'Not compatible with OpenAI /v1/images/generations endpoint.',
},
]
# Create all models
for model_data in text_models + image_models:
AIModelConfig.objects.create(**model_data)
def reverse_seed(apps, schema_editor):
"""Remove seeded data"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
AIModelConfig.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('billing', '0019_populate_token_based_config'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='HistoricalAIModelConfig',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('model_name', models.CharField(db_index=True, help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')", max_length=100)),
('display_name', models.CharField(help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')", max_length=200)),
('model_type', models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation'), ('embedding', 'Embedding')], db_index=True, help_text='Type of model - determines which pricing fields are used', max_length=20)),
('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='AI provider (OpenAI, Anthropic, etc.)', max_length=50)),
('input_cost_per_1m', models.DecimalField(blank=True, decimal_places=4, help_text='Cost per 1 million input tokens (USD). For text models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('output_cost_per_1m', models.DecimalField(blank=True, decimal_places=4, help_text='Cost per 1 million output tokens (USD). For text models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('context_window', models.IntegerField(blank=True, help_text='Maximum input tokens (context length). For text models only.', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('max_output_tokens', models.IntegerField(blank=True, help_text='Maximum output tokens per request. For text models only.', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('cost_per_image', models.DecimalField(blank=True, decimal_places=4, help_text='Fixed cost per image generation (USD). For image models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('valid_sizes', models.JSONField(blank=True, help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.', null=True)),
('supports_json_mode', models.BooleanField(default=False, help_text='True for models with JSON response format support')),
('supports_vision', models.BooleanField(default=False, help_text='True for models that can analyze images')),
('supports_function_calling', models.BooleanField(default=False, help_text='True for models with function calling capability')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Enable/disable model without deleting')),
('is_default', models.BooleanField(db_index=True, default=False, help_text='Mark as default model for its type (only one per type)')),
('sort_order', models.IntegerField(default=0, help_text='Control order in dropdown lists (lower numbers first)')),
('description', models.TextField(blank=True, help_text='Admin notes about model usage, strengths, limitations')),
('release_date', models.DateField(blank=True, help_text='When model was released/added', null=True)),
('deprecation_date', models.DateField(blank=True, help_text='When model will be removed', null=True)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(blank=True, db_constraint=False, help_text='Admin who last updated', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical AI Model Configuration',
'verbose_name_plural': 'historical AI Model Configurations',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='AIModelConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model_name', models.CharField(db_index=True, help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')", max_length=100, unique=True)),
('display_name', models.CharField(help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')", max_length=200)),
('model_type', models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation'), ('embedding', 'Embedding')], db_index=True, help_text='Type of model - determines which pricing fields are used', max_length=20)),
('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='AI provider (OpenAI, Anthropic, etc.)', max_length=50)),
('input_cost_per_1m', models.DecimalField(blank=True, decimal_places=4, help_text='Cost per 1 million input tokens (USD). For text models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('output_cost_per_1m', models.DecimalField(blank=True, decimal_places=4, help_text='Cost per 1 million output tokens (USD). For text models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('context_window', models.IntegerField(blank=True, help_text='Maximum input tokens (context length). For text models only.', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('max_output_tokens', models.IntegerField(blank=True, help_text='Maximum output tokens per request. For text models only.', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('cost_per_image', models.DecimalField(blank=True, decimal_places=4, help_text='Fixed cost per image generation (USD). For image models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('valid_sizes', models.JSONField(blank=True, help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.', null=True)),
('supports_json_mode', models.BooleanField(default=False, help_text='True for models with JSON response format support')),
('supports_vision', models.BooleanField(default=False, help_text='True for models that can analyze images')),
('supports_function_calling', models.BooleanField(default=False, help_text='True for models with function calling capability')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Enable/disable model without deleting')),
('is_default', models.BooleanField(db_index=True, default=False, help_text='Mark as default model for its type (only one per type)')),
('sort_order', models.IntegerField(default=0, help_text='Control order in dropdown lists (lower numbers first)')),
('description', models.TextField(blank=True, help_text='Admin notes about model usage, strengths, limitations')),
('release_date', models.DateField(blank=True, help_text='When model was released/added', null=True)),
('deprecation_date', models.DateField(blank=True, help_text='When model will be removed', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, help_text='Admin who last updated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_model_updates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'AI Model Configuration',
'verbose_name_plural': 'AI Model Configurations',
'db_table': 'igny8_ai_model_config',
'ordering': ['model_type', 'sort_order', 'model_name'],
'indexes': [models.Index(fields=['model_type', 'is_active'], name='igny8_ai_mo_model_t_1eef71_idx'), models.Index(fields=['provider', 'is_active'], name='igny8_ai_mo_provide_fbda6c_idx'), models.Index(fields=['is_default', 'model_type'], name='igny8_ai_mo_is_defa_95bfb9_idx')],
},
),
# Seed initial model data
migrations.RunPython(seed_ai_models, reverse_seed),
]

View File

@@ -142,3 +142,59 @@ class UsageLimitsSerializer(serializers.Serializer):
"""Serializer for usage limits response"""
limits: LimitCardSerializer = LimitCardSerializer(many=True)
class AIModelConfigSerializer(serializers.Serializer):
"""
Serializer for AI Model Configuration (Read-Only API)
Provides model information for frontend dropdowns and displays
"""
model_name = serializers.CharField(read_only=True)
display_name = serializers.CharField(read_only=True)
model_type = serializers.CharField(read_only=True)
provider = serializers.CharField(read_only=True)
# Text model fields
input_cost_per_1m = serializers.DecimalField(
max_digits=10,
decimal_places=4,
read_only=True,
allow_null=True
)
output_cost_per_1m = serializers.DecimalField(
max_digits=10,
decimal_places=4,
read_only=True,
allow_null=True
)
context_window = serializers.IntegerField(read_only=True, allow_null=True)
max_output_tokens = serializers.IntegerField(read_only=True, allow_null=True)
# Image model fields
cost_per_image = serializers.DecimalField(
max_digits=10,
decimal_places=4,
read_only=True,
allow_null=True
)
valid_sizes = serializers.ListField(read_only=True, allow_null=True)
# Capabilities
supports_json_mode = serializers.BooleanField(read_only=True)
supports_vision = serializers.BooleanField(read_only=True)
supports_function_calling = serializers.BooleanField(read_only=True)
# Status
is_default = serializers.BooleanField(read_only=True)
sort_order = serializers.IntegerField(read_only=True)
# Computed field
pricing_display = serializers.SerializerMethodField()
def get_pricing_display(self, obj):
"""Generate pricing display string based on model type"""
if obj.model_type == 'text':
return f"${obj.input_cost_per_1m}/{obj.output_cost_per_1m} per 1M"
elif obj.model_type == 'image':
return f"${obj.cost_per_image} per image"
return ""

View File

@@ -751,3 +751,75 @@ class AdminBillingViewSet(viewsets.ViewSet):
return Response({'error': 'Method not found'}, status=404)
@extend_schema_view(
list=extend_schema(tags=['AI Models'], summary='List available AI models'),
retrieve=extend_schema(tags=['AI Models'], summary='Get AI model details'),
)
class AIModelConfigViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for AI Model Configuration (Read-Only)
Provides model information for frontend dropdowns and displays
"""
permission_classes = [IsAuthenticatedAndActive]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
throttle_scope = 'billing'
throttle_classes = [DebugScopedRateThrottle]
pagination_class = None # No pagination for model lists
lookup_field = 'model_name'
def get_queryset(self):
"""Get AIModelConfig queryset with filters"""
from igny8_core.business.billing.models import AIModelConfig
queryset = AIModelConfig.objects.filter(is_active=True)
# Filter by model type
model_type = self.request.query_params.get('type', None)
if model_type:
queryset = queryset.filter(model_type=model_type)
# Filter by provider
provider = self.request.query_params.get('provider', None)
if provider:
queryset = queryset.filter(provider=provider)
# Filter by default
is_default = self.request.query_params.get('default', None)
if is_default is not None:
is_default_bool = is_default.lower() in ['true', '1', 'yes']
queryset = queryset.filter(is_default=is_default_bool)
return queryset.order_by('model_type', 'sort_order', 'model_name')
def get_serializer_class(self):
"""Return serializer class"""
from .serializers import AIModelConfigSerializer
return AIModelConfigSerializer
def list(self, request, *args, **kwargs):
"""List all available models with filters"""
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return success_response(
data=serializer.data,
message='AI models retrieved successfully'
)
def retrieve(self, request, *args, **kwargs):
"""Get details for a specific model"""
try:
instance = self.get_queryset().get(model_name=kwargs.get('model_name'))
serializer = self.get_serializer(instance)
return success_response(
data=serializer.data,
message='AI model details retrieved successfully'
)
except Exception as e:
return error_response(
message='Model not found',
errors={'model_name': [str(e)]},
status_code=status.HTTP_404_NOT_FOUND
)

826
contetn-update.md Normal file
View File

@@ -0,0 +1,826 @@
IGNY8 UX Improvement Instructions for Claude Sonnet 4.5
Overview
This document contains detailed instructions for improving text clarity, labels, and user-friendliness across all pages of the IGNY8 application. The changes focus on making the interface more intuitive for non-technical users by simplifying language, adding helpful explanations, and clarifying feature purposes.
1. DASHBOARD PAGE
Current Issues:
"Your Progress" section title is vague
"Overall Completion" metric needs context about what it measures
Icon labels are too formal/technical
Instructions for Improvement:
Section A: Header Area
Change "Dashboard" heading to add a short welcome: "Your Content Creation Dashboard"
Change "Last updated" label to: "Last checked: [timestamp]" (more conversational)
Add tooltip to "Refresh" button: "Click to get the latest updates on your content creation progress"
Section B: Main Banner
Keep "AI-Powered Content Creation Workflow" but add subtitle explanation below current text:
Add: "Your complete toolkit for finding topics, creating content, and publishing it to your site - all automated"
Change "2/5 Sites" to "2 of 5 Sites Active" (clearer fraction meaning)
Section C: Your Progress Card
Change "Your Progress" to "Your Content Journey" (more relatable)
Change "Track your content creation workflow completion" to:
"See how far you've come in creating and publishing content"
Add a progress explanation right above the bar:
"Overall Completion: 83% - You're making great progress!"
Add micro-explanation: "(This shows your progress from keywords through to published content)"
Section D: Metric Cards (Site & Sectors, Keywords, Clusters, Ideas, Content, Published)
For each card, keep the main number but enhance the description:
Site & Sectors: Change "Industry & sectors configured" to "Niches you're targeting - Industry & sectors set up"
Keywords: Change "Keywords added from opportunities" to "Search terms to target - Keywords added from research"
Clusters: Change "Keywords grouped into clusters" to "Topic groups - Keywords organized by theme"
Ideas: Change "Content ideas and outlines" to "Article outlines ready - Ideas and outlines created"
Content: Change "Content pieces + images created" to "Articles created - Written content + images ready"
Published: Change "Content published to site" to "Live on your site - Articles published and active"
Section E: Bottom Cards (Quick Actions area)
Make titles more action-oriented and clear:
"Keyword Research" → "Find Keywords to Rank For" + add: "Search for topics your audience wants to read about"
"Clustering & Ideas" → "Organize Topics & Create Outlines" + add: "Group keywords and create article plans"
"Content Generation" → "Write Articles with AI" + add: "Generate full articles ready to publish"
"Internal Linking" → "Connect Your Articles" + add: "Automatically link related articles for better SEO"
"Content Optimization" → "Make Articles Better" + add: "Improve readability, keywords, and search rankings"
"Image Generation" → "Create Article Images" + add: "Generate custom images for your content"
"Automation" → "Run Everything Automatically" + add: "Set up schedules to create and publish content on its own"
"Prompts" → "Customize Your AI Writer" + add: "Create custom instructions for how AI writes your content"
2. ADD KEYWORDS PAGE
Current Issues:
"Select a Sector to Add Keywords" instruction is confusing
Column headers use technical jargon
Status like "Added" needs context
Instructions for Improvement:
Section A: Page Header
Change title to: "Find & Add Keywords to Target"
Change description "Select a sector from the dropdown above to enable adding keywords..." to:
"Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target"
Section B: Sector Selector
Change label "Sector: All Sectors" to: "What topic area? - All Sectors"
Add helpful text below: "Select a niche or topic - This tells our AI what type of content you create"
Section C: Table Headers
"KEYWORD" → "Search Term" (what people actually search for)
"SECTOR" → "Topic Area" (the category)
"VOLUME" → "How Often Searched" (what this metric means)
"DIFFICULTY" → "Competition Level" (easier to understand than "difficulty")
"COUNTRY" → "Target Location" (clearer purpose)
"STATUS" column: Add tooltip explaining "Added" means "Selected for your content plan"
Section D: Show Filters Button
Keep button but add tooltip: "Click to filter keywords by search volume, difficulty, or other details"
Section E: Bulk Actions
Change "Bulk Actions" label to: "Do Multiple at Once" with tooltip: "Select keywords and perform actions on all of them together"
3. SITES MANAGEMENT PAGE
Current Issues:
Page layout is good but labels could be clearer
"Dashboard" and "Content" buttons' purposes aren't obvious
Instructions for Improvement:
Section A: Page Header
Change "Sites Management" to: "Your Websites"
Change description to: "Manage all your websites here - Add new sites, configure settings, and track content for each one"
Section B: Add Site Button
Change "Add Site" to: "+ Add Another Website"
Add tooltip: "Connect a new WordPress or Shopify site to create content for it"
Section C: Filter Dropdowns
"All Types" → "Show All Types"
"All Hosting" → "Show All Hosting"
"All Status" → "Show All Status"
Add micro-helper text: "Filter by site type, hosting provider, or active status"
Section D: Site Cards
Keep the site card design but clarify buttons:
"Dashboard" → "View Site Dashboard" with tooltip: "See overview and statistics for this site"
"Content" → "Manage Content" with tooltip: "Add, edit, or view all articles for this site"
"Settings" → "Configure Site" with tooltip: "Update connection details and publishing settings"
Section E: Active/Inactive Status
Add explanation near toggle: "Active sites can receive new content. Inactive sites are paused."
4. THINKER (AI PROMPTS MANAGEMENT) PAGE
Current Issues:
"Thinker" is an abstract/unclear name for this feature
"Prompts" is jargon-heavy for non-technical users
Tabs could have better explanations
Instructions for Improvement:
Section A: Page Header
Change "AI Prompts Management" to: "Customize Your AI Writer"
Change description "Configure AI prompt templates..." to:
"Tell our AI how you want it to write - Create custom instructions and templates for different types of content"
Section B: Tab Labels
"Prompts" → "Writing Styles" (easier to understand)
"Author Profiles" → "Writing Voices" (clearer)
"Strategies" → "Content Strategies" (more descriptive)
"Image Testing" → "Test Image Settings" (clearer action)
Section C: Prompts Sub-Sections
"Planner Prompts" → "AI Instructions for Planning"
Add explanation: "These instructions tell our AI how to organize your keywords into topics and create outlines"
"Clustering Prompt" → "How to Organize Keywords"
Add description: "This tells our AI how to group related keywords into topic clusters"
"Ideas Generation Prompt" → "How to Create Article Outlines"
Add description: "This tells our AI how to generate article ideas and outlines for each topic cluster"
"Writer Prompts" section → "AI Instructions for Writing"
Add explanation: "These control how our AI writes your full articles"
Section D: Prompt Editor Buttons
"Reset to Default" → "Restore Original"
"Save Prompt" → "Save My Custom Instructions"
5. PLANNER PAGE (Keywords View)
Current Issues:
"Pipeline readiness at 22%" is confusing without context
Column headers are technical
Warning messages use jargon
Instructions for Improvement:
Section A: Page Header
Change title to: "Organize Your Keywords"
Change description to: "Group keywords into topic clusters and plan your content - Get keywords ready to write about"
Section B: Status Alerts
Current: "Pipeline readiness at 22% - Most keywords need clustering..."
Change to: "You're 22% ready to start writing - Next step: Group your keywords by topic (36 keywords are ready to organize)"
Section C: Top Statistics
Display bar shows: KEYWORDS, CLUSTERS, UNMAPPED, VOLUME
Change "UNMAPPED" to "READY TO ORGANIZE" (clearer)
Add tooltip to each stat explaining what it means
Section D: Bulk Actions Button
Change to: "Do Multiple at Once"
Add tooltip: "Select keywords and apply actions to all of them together"
Section E: Table Headers
"KEYWORD" → "Search Term"
"SECTOR" → "Topic Area"
"VOLUME" → "Monthly Searches" (clearer)
"CLUSTER" → "Grouped Under" or "Topic Group"
"DIFFICULTY" → "Competition Level"
"COUNTRY" → "Target Country"
"STATUS" → "Prep Status"
"CREATED" → "Date Added"
Section F: Import Button
Change label to: "+ Import More Keywords"
6. PLANNER PAGE (Clusters View)
Current Issues:
"Clusters" is abstract for non-technical users
Navigation between Keywords and Clusters tabs needs better clarity
Instructions for Improvement:
Section A: Tab Area
"Keywords" tab → keep but add small label: "Keywords (individual terms)"
"Clusters" tab → change to "Topics (keyword groups)"
Add short explanation at top: "See your keyword groups - Clusters are groups of related keywords organized by topic"
Section B: Cluster Items
Display of clusters should include a clear purpose statement
Add count of keywords in each cluster with explanation: "5 keywords in this group" (instead of just "5")
7. WRITER PAGE (Content Queue)
Current Issues:
"Content Queue" is vague
Tab names don't clearly indicate what content state they represent
Status indicators like "Queued" need explanation
Instructions for Improvement:
Section A: Page Header
Change title to: "Write Your Articles"
Change description to: "Create and manage all your article content - Write, review, and publish articles one by one or all at once"
Section B: Status Alerts
Current: "1 tasks in queue - Content generation pipeline is active..."
Change to: "You have 1 article waiting to be written - Our AI is ready to create it. High completion rate (82%) - 9 pieces of content are ready to review"
Section C: Tab Names
"Queue" → "Ready to Write" (clearer - these are articles waiting)
"Drafts" → "Finished Drafts" (clearer - these are completed)
"Images" → "Article Images" (more specific)
"Review" → "Review Before Publishing" (clearer action)
Section D: Bulk Actions
Change to: "Do Multiple at Once"
Section E: Table Headers & Status
"TITLE" → "Article Title"
"SECTOR" → "Topic Area"
"CLUSTER" → "Topic Group"
"TYPE" → "Content Type" (Post, Page, etc.)
"STRUCTURE" → "Article Format"
"STATUS" → "Current State"
"WORD COUNT" → "Word Count"
Change status labels:
"Completed" → "Done - Ready to Review"
"Queued" → "Waiting to be Written"
"Failed" → "Error - Needs Help"
Section F: Content Type Examples
Add explanation popup for content types:
"Post" = "Blog article (standard format)"
"Page" = "Standalone page (no categories)"
"Guide" = "Comprehensive how-to guide"
"Tutorial" = "Step-by-step instructional content"
8. AUTOMATION PAGE (AI Automation Pipeline)
Current Issues:
Visual pipeline is good but stage names are vague
"Ready to Run" status and 34 items isn't clear about what they are
Stage descriptions are too technical
Instructions for Improvement:
Section A: Page Header
Change title to: "Automate Everything"
Change description to: "Set your content on automatic - Let our AI create and publish content on a schedule"
Section B: Status Badge
"Ready to Run - 34 items in pipeline" → "Ready to Go! 34 items waiting - Everything is queued up and ready for the next run"
Section C: Schedule Display
Current: "Daily at 02:00:00 | Last: Never | Est: 5 credits"
Change to: "Runs every day at 2:00 AM | Last run: Never | Uses about 5 credits per run"
Section D: Pipeline Statistics
Add explanatory text above stats:
"Here's what's in your automation pipeline:"
Keep numbers but change labels:
"Keywords 46" → "46 Search Terms (waiting to organize)"
"Clusters 4" → "4 Topic Groups (ready for ideas)"
"Ideas 16" → "16 Article Ideas (waiting to write)"
"Content 10" → "10 Articles (in various stages)"
"Images 10" → "10 Images (created and waiting)"
Section E: Stage Names & Explanations
Replace technical names with clear actions + explanations:
Stage 1: Keywords → Clusters
Change to: "ORGANIZE KEYWORDS"
Description: "Group related search terms into topic clusters"
Stage 2: Clusters → Ideas
Change to: "CREATE ARTICLE IDEAS"
Description: "Generate article titles and outlines for each cluster"
Stage 3: Ideas → Tasks
Change to: "PREPARE WRITING JOBS"
Description: "Convert ideas into tasks for the AI writer"
Stage 4: Tasks → Content
Change to: "WRITE ARTICLES"
Description: "AI generates full, complete articles"
Stage 5: Content → Image Prompts
Change to: "CREATE IMAGE DESCRIPTIONS"
Description: "Generate descriptions for AI to create images"
Stage 6: Image Prompts → Images
Change to: "GENERATE IMAGES"
Description: "AI creates custom images for your articles"
Stage 7: Manual Review Gate + Publishing
Change to: "REVIEW & PUBLISH ⚠️"
Description: "Review 3 articles before they go live" (manual approval needed)
Final: Published
Change to: "LIVE ON YOUR SITE"
Description: "Articles are now published and visible"
Section F: Configuration Button
Change "Configure" to: "⚙️ Adjust Settings"
Add tooltip: "Change when this automation runs and how many credits it uses"
Section G: Run Now Button
Keep "Run Now" but add tooltip: "Start the automation immediately instead of waiting for the scheduled time"
9. ACCOUNT SETTINGS PAGE
Current Issues:
Labels are formal/corporate
Sections could use more context
Billing-related fields aren't clear about their purpose
Instructions for Improvement:
Section A: Page Header
Change title to: "Your Account Info"
Change description to: "Keep your information updated - Your account name, email, and billing address"
Section B: Account Information Section
Change section title to: "Basic Account Details"
"Account Name" → "Your Account Name" + helper: "This is how you'll see your account (for you only)"
"Account Slug" → "Account URL Name" + helper: "Used in web addresses (usually matches your company name)"
"Billing Email" → "Email for Receipts" + helper: "Where invoices and billing updates will be sent"
Section C: Billing Address Section
Change to: "Where to Send Invoices"
Add intro text: "Tell us your official business address for billing"
"Address Line 1" → "Street Address"
"Address Line 2" → "Apartment, Suite, etc. (optional)"
"City" → "City"
"State/Province" → "State or Province"
"Postal Code" → "ZIP or Postal Code"
"Country" → "Country"
10. TEAM MANAGEMENT PAGE
Current Issues:
"Users" vs "Invitations" tabs are okay but could be clearer
"Access Control" is abstract
Instructions for Improvement:
Section A: Page Header
Change title to: "Your Team"
Change description to: "Manage who can access your account - Add team members and control what they can do"
Section B: Tab Navigation
"Users" → "Team Members (Active)"
"Invitations" → "Pending Invites (Waiting to Join)"
"Access Control" → "Permissions (What they can do)"
Section C: Users Tab Content
"Name" column → "Member Name"
"Email" column → "Email Address"
"Status" column → "Account Status" (with "Active" explanation)
"Role" column → "Permission Level"
"Joined" column → "Date Joined"
"Last Login" column → "Last Active"
"Actions" column → keep but add tooltip: "Remove this person's access"
Section D: Invite Button
Change "Invite Team Member" to: "+ Invite Someone"
Add tooltip: "Send an invitation to someone to join your team"
Section E: Permission Levels (in Access Control)
Explain each role simply:
"Admin" = "Full access to everything"
"Member" = "Can create and edit content"
"Viewer" = "Can only view reports"
11. PLANS & BILLING PAGE
Current Issues:
"Growth" plan name is vague
Feature limits and metrics need context
Tab purposes could be clearer
Instructions for Improvement:
Section A: Page Header
Change title to: "Your Subscription"
Change description to: "Manage your plan and payments - View what's included, upgrade, or buy more credits"
Section B: Current Plan Section
Change "Your Current Plan" to: "What You're Using Now"
"Growth" → "Growth Plan (Our most popular)"
Change "Select a plan to unlock full access" to: "Want more? Upgrade your plan for more content limits and features"
Section C: Plan Features Card
"Included Features" → "What's in Your Plan"
List items with context:
"5 Sites" → "5 Websites - You can manage up to 5 websites"
"1000 Keywords" → "1,000 Keywords - You can track up to 1,000 search terms per month"
"3000 Credits" → "3,000 Credits - Credits are used to run automation and create content"
"300K Words" → "300,000 Words - About how much content you can generate per month"
"200 Clusters" → "200 Topic Groups - You can organize keywords into up to 200 clusters"
"900 Images" → "900 Images - AI can generate up to 900 images per month"
Section D: Tab Names
"Current Plan" → keep (it's clear)
"Plan Limits" → "Your Limits (What you can do)"
"Credits" → "Credits & Balance (How much you have left)"
"Purchase/Upgrade" → "Buy More (Get more credits or upgrade)"
"History" → "Billing History (Past invoices and charges)"
Section E: Buttons
"Purchase Credits" → "+ Buy More Credits" + tooltip: "Add credits to your account for more content generation"
"View Limits" → "See Your Limits" + tooltip: "Check how much of each feature you're using"
12. USAGE & ANALYTICS PAGE
Current Issues:
Statistics are presented but lack context
"Usage %" without explanation of what 39% means
Section titles are vague
Instructions for Improvement:
Section A: Page Header
Change title to: "Your Usage"
Change description to: "See how much you're using - Track your credits, content limits, and API activity"
Section B: Stats Cards at Top
"Current Balance 835" → "Credits Left: 835" + explanation: "You have 835 credits available"
"Used This Month 1,165" → "Credits Used This Month: 1,165" + explanation: "How many credits you've spent so far"
"Monthly Allocation 3,000" → "Your Monthly Limit: 3,000" + explanation: "Total credits you get each month"
"Usage % 39%" → "39% Used" + explanation: "You've used 39% of your monthly credits. You have 61% left"
Section C: Tab Names
"Plan Limits & Usage" → "Your Limits & Usage (What you're using)"
"Credit Activity" → "Credit History (Where credits go)"
"API Usage" → "API Activity (Technical requests)"
Section D: Account Limits Section
Change title to: "Your Account Limits"
Add intro: "Here's how much of each feature you're using:"
Display each limit as a category card:
"Sites" → "Websites: 2 of 5 Used" with explanation: "You're using 40% of your site limit"
"Team Users" → "Team Members: 2 of 3 Used" with explanation: "You can add 1 more person"
"Keywords" → "Search Terms: 46 of 1,000 Used" with explanation: "You're using 5% of your keyword limit"
"Clusters" → "Topic Groups: 4 of 200 Used" with explanation: "Plenty of room for more topics"
Section E: Monthly Usage Limits
Change title to: "What You Can Create This Month"
Add intro: "These reset on the 1st of each month:"
"Content Ideas" → "Article Ideas: 105 of 900 Used" (chart showing usage)
"Content Words" → "Article Words: 41,377 of 300,000 Used" (chart showing usage)
"Basic Images" → "AI Images: 81 of 900 Used" (chart showing usage)
13. PROFILE SETTINGS PAGE
Current Issues:
"Personal Information" is clear but could be more friendly
Language/timezone settings are important but buried
Instructions for Improvement:
Section A: Page Header
Change title to: "Your Profile"
Change description to: "Update your personal settings - Your name, preferences, and notification choices"
Section B: Personal Information Section
Change section title to: "About You"
"First Name" → "First Name"
"Last Name" → "Last Name"
"Email" → "Email Address"
"Phone" → "Phone Number (optional)"
Section C: Preferences Section
Change to: "How You Like It"
"Timezone" → "Your Timezone" + explanation: "We use this to show you times that match your location"
"Language" → "Language" + explanation: "The language we'll use to talk to you"
Section D: Notifications Section
Change to: "What You Want to Hear About"
Add intro: "Choose what emails you want to receive:"
"Email Notifications" → "Important Updates" + explanation: "Get notified about important changes to your account"
"Marketing Emails" → "Tips & Product Updates (optional)" + explanation: "Hear about new features and content tips"
Section E: Save Button
Change "Save Changes" to: "✓ Save My Settings"
14. PUBLISHING SETTINGS PAGE
Current Issues:
"Default Publishing Destinations" is clear but could use more context
"Auto-Sync" is technical jargon
Instructions for Improvement:
Section A: Page Header
Change title to: "Where to Publish"
Change description to: "Set up automatic publishing - Tell us where your content should go"
Section B: Default Publishing Destinations
Change title to: "Where Should Articles Go?"
Change description to: "Choose which platforms get your articles - You can pick multiple"
Add explanation: "When you publish an article, it will go to all the platforms you check here"
Checkboxes:
"IGNY8 Sites" → "Publish to My Sites (using IGNY8)"
"WordPress" → "Publish to WordPress (your self-hosted WordPress site)"
"Shopify" → "Publish to Shopify (your Shopify store)"
Section C: Auto-Publish Settings
Change title to: "Automatic Publishing"
Change description to: "Publish articles without asking me"
Add checkbox label: "Automatically publish articles when they're finished and reviewed"
Add explanation: "When you turn this on, articles will publish to your site right away. You can still review them first if you want"
Section D: Auto-Sync Settings
Change title to: "Keep Everything Updated"
Change description to: "Automatically sync articles between platforms"
Add checkbox label: "Automatically update articles on all my platforms if I make changes"
Add explanation: "When you edit an article, this updates it everywhere - on your site, WordPress, etc."
Section E: Publishing Rules
Change title to: "Advanced Publishing Rules"
Change description to: "Set specific rules for different types of content"
Add button: "+ Add a Publishing Rule"
Add explanation: "Example: Publish blog posts to WordPress but guides to your main site"
15. IMPORT / EXPORT PAGE
Current Issues:
Page says "Coming Soon" with vague description
Users don't understand what this feature will do
Instructions for Improvement:
Section A: Coming Soon Banner
Change title to: "Coming Soon: Manage Your Data"
Change description from "Data management" to clearer explanation:
"Import and Export Your Content - Backup your keywords, articles, and settings. Move your content to other platforms. Download everything safely."
Add sub-points explaining what will be available:
"✓ Export your keywords as a file (backup or share)"
"✓ Export all your articles in different formats"
"✓ Import keywords from other sources"
"✓ Backup and restore your entire account"
"✓ Download your settings and configurations"
16. HELP & DOCUMENTATION PAGE
Current Issues:
Page is good but "Table of Contents" could be friendlier
Some section titles use module names instead of plain English
Instructions for Improvement:
Section A: Page Header
Keep "Help & Documentation" but improve description:
Change to: "Learn How to Use IGNY8 - Step-by-step guides and answers to common questions"
Section B: Table of Contents
Change "Table of Contents" to: "What Do You Want to Learn?"
Reorganize and rename sections:
"Getting Started" → "I'm New - Help Me Get Started!"
Links: Quick Start Guide, Workflow Overview
"Planner Module" → "How to Organize Keywords"
Links: Keywords Management, Keyword Clusters, Content Ideas
"Writer Module" → "How to Write Content"
Links: Tasks Management, Content Generation, Image Generation
"Automation Setup" → "Set Up Automation"
Links: (same)
"Frequently Asked Questions" → "Common Questions Answered"
Section C: Quick Start Guide
Change introduction text:
Current: "Welcome to IGNY8! Follow these steps to get started with content creation:"
New: "Let's Get You Creating Content! Follow these simple steps:"
Section D: Step Descriptions
"Set Up Your Site" → "Step 1: Connect Your Website"
Explanation: "Tell IGNY8 which website you want to create content for"
"Discover Keywords" → "Step 2: Find Search Terms to Target"
Explanation: "Search for keywords people are looking for in your topic area"
All other steps follow same pattern: Simple number, action, and plain-English explanation
Section E: Module Descriptions
"Planner Module" → "Organizing Phase"
"Writer Module" → "Writing Phase"
"Automation" → "Automatic Phase"
Add descriptions in plain English for each
17. SIDEBAR NAVIGATION
Current Issues:
Section headers (SETUP, WORKFLOW, ACCOUNT, SETTINGS, HELP & DOCS) are okay but could be more intuitive
Some menu items don't clearly indicate what they do
Instructions for Improvement:
Section A: Section Headers
"SETUP" → "GET STARTED" (clearer that this is initial setup)
"WORKFLOW" → "CREATE CONTENT" (clearer about the main activities)
"ACCOUNT" → "MANAGE ACCOUNT" (clear)
"SETTINGS" → "CONFIGURATION" (clearer than just "SETTINGS")
"HELP & DOCS" → "HELP & LEARNING" (friendlier)
Section B: Menu Item Names
"Add Keywords" → "Find Keywords" (clearer action)
"Thinker" → "AI Writer Setup" (explains what it is)
"Planner" → "Organize Keywords" (explains the action)
"Writer" → "Write Articles" (clearer)
"Automation" → "Automate Everything" (clearer value)
Section C: Icons
Keep visual icons but add small text labels on hover explaining each one
Summary of Key Principles Applied:
Remove Jargon: Replace technical terms with everyday language
Add Context: Explain WHY users need to do something, not just HOW
Clarify Status: Explain what status badges and messages mean
Use Friendly Tone: "Let's get started" instead of "Configure"
Add Micro-Explanations: One-line helper text on key elements
Action-Oriented Labels: "Write Articles" not "Writer Module"
Number + Meaning: "39% Used - You have 61% left" not just "39%"
Tooltip Helpers: Hover explanations for complex concepts
Simple Descriptions: Use active voice and clear actions
Progressive Disclosure: Hide advanced features but make them discoverable

View File

@@ -345,19 +345,76 @@ const IntroBlock = ({ html }: { html: string }) => (
</section>
);
// Helper to split content at first H3 tag
const splitAtFirstH3 = (html: string): { beforeH3: string; h3AndAfter: string } => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const h3 = doc.querySelector('h3');
if (!h3) {
return { beforeH3: html, h3AndAfter: '' };
}
const beforeNodes: Node[] = [];
const afterNodes: Node[] = [];
let foundH3 = false;
Array.from(doc.body.childNodes).forEach((node) => {
if (node === h3) {
foundH3 = true;
afterNodes.push(node);
} else if (foundH3) {
afterNodes.push(node);
} else {
beforeNodes.push(node);
}
});
const serializeNodes = (nodes: Node[]): string =>
nodes
.map((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
return (node as HTMLElement).outerHTML;
}
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent ?? '';
}
return '';
})
.join('');
return {
beforeH3: serializeNodes(beforeNodes),
h3AndAfter: serializeNodes(afterNodes),
};
};
// Helper to check if section contains a table
const hasTable = (html: string): boolean => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return doc.querySelector('table') !== null;
};
const ContentSectionBlock = ({
section,
image,
loading,
index,
imagePlacement = 'right',
firstImage = null,
}: {
section: ArticleSection;
image: ImageRecord | null;
loading: boolean;
index: number;
imagePlacement?: 'left' | 'center' | 'right';
firstImage?: ImageRecord | null;
}) => {
const hasImage = Boolean(image);
const headingLabel = section.heading || `Section ${index + 1}`;
const sectionHasTable = hasTable(section.bodyHtml);
const { beforeH3, h3AndAfter } = splitAtFirstH3(section.bodyHtml);
return (
<section id={section.id} className="group/section scroll-mt-24">
@@ -377,16 +434,86 @@ const ContentSectionBlock = ({
</div>
</div>
<div className={hasImage ? 'grid gap-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]' : ''}>
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert ${hasImage ? '' : ''}`}>
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
</div>
{hasImage && (
<div className="flex flex-col gap-4">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
{imagePlacement === 'center' && hasImage ? (
<div className="flex flex-col gap-10">
{/* Content before H3 */}
{beforeH3 && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
</div>
)}
{/* Centered image before H3 */}
<div className="flex justify-center">
<div className="w-full max-w-[60%]">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
</div>
)}
</div>
{/* H3 and remaining content */}
{h3AndAfter && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
</div>
)}
{/* Fallback if no H3 found */}
{!beforeH3 && !h3AndAfter && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
</div>
)}
</div>
) : sectionHasTable && hasImage && firstImage ? (
<div className="flex flex-col gap-10">
{/* Content before H3 */}
{beforeH3 && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
</div>
)}
{/* Two images side by side at 50% width each */}
<div className="grid grid-cols-2 gap-6">
<div className="w-full">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
<div className="w-full">
<SectionImageBlock image={firstImage} loading={loading} heading="First Article Image" />
</div>
</div>
{/* H3 and remaining content */}
{h3AndAfter && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
</div>
)}
{/* Fallback if no H3 found */}
{!beforeH3 && !h3AndAfter && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
</div>
)}
</div>
) : (
<div className={hasImage ? `grid gap-10 ${imagePlacement === 'left' ? 'lg:grid-cols-[minmax(0,40%)_minmax(0,60%)]' : 'lg:grid-cols-[minmax(0,60%)_minmax(0,40%)]'}` : ''}>
{imagePlacement === 'left' && hasImage && (
<div className="flex flex-col gap-4">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
)}
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
</div>
{imagePlacement === 'right' && hasImage && (
<div className="flex flex-col gap-4">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
)}
</div>
)}
</div>
</div>
</section>
@@ -404,6 +531,14 @@ interface ArticleBodyProps {
const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => {
const hasStructuredSections = sections.length > 0;
// Calculate image placement: right → center → left → repeat
const getImagePlacement = (index: number): 'left' | 'center' | 'right' => {
const position = index % 3;
if (position === 0) return 'right';
if (position === 1) return 'center';
return 'left';
};
if (!hasStructuredSections && !introHtml && rawHtml) {
return (
<div className="overflow-hidden rounded-3xl border border-slate-200/80 bg-white/90 p-8 shadow-lg shadow-slate-200/50 dark:border-gray-800/70 dark:bg-gray-900/70 dark:shadow-black/20">
@@ -414,6 +549,9 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
);
}
// Get the first in-article image (position 0)
const firstImage = sectionImages.length > 0 ? sectionImages[0] : null;
return (
<div className="space-y-12">
{introHtml && <IntroBlock html={introHtml} />}
@@ -424,6 +562,8 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
image={sectionImages[index] ?? null}
loading={imagesLoading}
index={index}
imagePlacement={getImagePlacement(index)}
firstImage={firstImage}
/>
))}
</div>
@@ -535,13 +675,13 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
const byPosition = new Map<number, ImageRecord>();
sorted.forEach((img, index) => {
const pos = img.position ?? index + 1;
const pos = img.position ?? index;
byPosition.set(pos, img);
});
const usedPositions = new Set<number>();
const merged: ImageRecord[] = prompts.map((prompt, index) => {
const position = index + 1;
const position = index; // 0-based position matching section array index
const existing = byPosition.get(position);
usedPositions.add(position);
if (existing) {
@@ -561,7 +701,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
image_path: undefined,
prompt,
status: 'pending',
position,
position, // 0-based position
created_at: '',
updated_at: '',
account_id: undefined,
@@ -569,7 +709,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
});
sorted.forEach((img, idx) => {
const position = img.position ?? idx + 1;
const position = img.position ?? idx;
if (!usedPositions.has(position)) {
merged.push(img);
}
@@ -596,7 +736,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-6"></div>
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
@@ -613,7 +753,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
if (!content) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
<XCircleIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Content Not Found</h2>
@@ -663,7 +803,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
{/* Back Button */}
{onBack && (
<button
@@ -676,7 +816,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
)}
{/* Main Content Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-hidden">
{/* Header Section */}
<div className="bg-gradient-to-r from-brand-500 to-brand-600 px-8 py-6 text-white">
<div className="flex items-start justify-between gap-4">
@@ -971,7 +1111,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
{/* Featured Image */}
{shouldShowFeaturedBlock && (
<div className="px-8 pt-8">
<div className="mb-12 max-w-[800px] mx-auto">
<FeaturedImageBlock image={resolvedFeaturedImage} loading={imagesLoading} />
</div>
)}
@@ -985,15 +1125,13 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
)}
{/* Article Body */}
<div className="px-8 pb-10 pt-10">
<ArticleBody
introHtml={parsedArticle.introHtml}
sections={parsedArticle.sections}
sectionImages={resolvedInArticleImages}
imagesLoading={imagesLoading}
rawHtml={content.content_html}
/>
</div>
<ArticleBody
introHtml={parsedArticle.introHtml}
sections={parsedArticle.sections}
sectionImages={resolvedInArticleImages}
imagesLoading={imagesLoading}
rawHtml={content.content_html}
/>
{/* Metadata JSON (Collapsible) */}
{content.metadata && Object.keys(content.metadata).length > 0 && (