Add Linker and Optimizer modules with API integration and frontend components
- Added Linker and Optimizer apps to `INSTALLED_APPS` in `settings.py`. - Configured API endpoints for Linker and Optimizer in `urls.py`. - Implemented `OptimizeContentFunction` for content optimization in the AI module. - Created prompts for content optimization and site structure generation. - Updated `OptimizerService` to utilize the new AI function for content optimization. - Developed frontend components including dashboards and content lists for Linker and Optimizer. - Integrated new routes and sidebar navigation for Linker and Optimizer in the frontend. - Enhanced content management with source and sync status filters in the Writer module. - Comprehensive test coverage added for new features and components.
This commit is contained in:
@@ -176,8 +176,7 @@ class OptimizerService:
|
||||
|
||||
def _optimize_content(self, content: Content, scores_before: dict) -> Content:
|
||||
"""
|
||||
Internal method to optimize content.
|
||||
This is a placeholder - in production, this would call the AI function.
|
||||
Internal method to optimize content using AI function.
|
||||
|
||||
Args:
|
||||
content: Content to optimize
|
||||
@@ -186,14 +185,30 @@ class OptimizerService:
|
||||
Returns:
|
||||
Optimized Content instance
|
||||
"""
|
||||
# For now, return content as-is
|
||||
# In production, this would:
|
||||
# 1. Call OptimizeContentFunction AI function
|
||||
# 2. Get optimized HTML
|
||||
# 3. Update content
|
||||
from igny8_core.ai.engine import AIEngine
|
||||
from igny8_core.ai.registry import get_function_instance
|
||||
|
||||
# Prepare payload for AI function
|
||||
payload = {
|
||||
'ids': [content.id],
|
||||
}
|
||||
|
||||
# Get function from registry
|
||||
fn = get_function_instance('optimize_content')
|
||||
if not fn:
|
||||
raise ValueError("OptimizeContentFunction not found in registry")
|
||||
|
||||
# Execute AI function
|
||||
ai_engine = AIEngine(account=content.account)
|
||||
result = ai_engine.execute(fn, payload)
|
||||
|
||||
if not result.get('success'):
|
||||
raise ValueError(f"Optimization failed: {result.get('error', 'Unknown error')}")
|
||||
|
||||
# The AI function's save_output method already updates the content
|
||||
# We just need to refresh from database to get the updated content
|
||||
content.refresh_from_db()
|
||||
|
||||
# Placeholder: We'll implement AI function call later
|
||||
# For now, just return the content
|
||||
return content
|
||||
|
||||
def analyze_only(self, content_id: int) -> dict:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Optimization tests
|
||||
|
||||
177
backend/igny8_core/business/optimization/tests/test_analyzer.py
Normal file
177
backend/igny8_core/business/optimization/tests/test_analyzer.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Tests for ContentAnalyzer
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class ContentAnalyzerTests(IntegrationTestBase):
|
||||
"""Tests for ContentAnalyzer"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.analyzer = ContentAnalyzer()
|
||||
|
||||
def test_analyze_returns_all_scores(self):
|
||||
"""Test that analyze returns all required scores"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test keyword",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertIn('seo_score', scores)
|
||||
self.assertIn('readability_score', scores)
|
||||
self.assertIn('engagement_score', scores)
|
||||
self.assertIn('overall_score', scores)
|
||||
self.assertIn('word_count', scores)
|
||||
self.assertIn('has_meta_title', scores)
|
||||
self.assertIn('has_meta_description', scores)
|
||||
self.assertIn('has_primary_keyword', scores)
|
||||
self.assertIn('internal_links_count', scores)
|
||||
|
||||
def test_analyze_returns_zero_scores_for_empty_content(self):
|
||||
"""Test that empty content returns zero scores"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Empty",
|
||||
html_content="",
|
||||
word_count=0,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertEqual(scores['seo_score'], 0)
|
||||
self.assertEqual(scores['readability_score'], 0)
|
||||
self.assertEqual(scores['engagement_score'], 0)
|
||||
self.assertEqual(scores['overall_score'], 0)
|
||||
|
||||
def test_calculate_seo_score_with_meta_title(self):
|
||||
"""Test SEO score calculation with meta title"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
meta_title="Test Title" * 5, # 50 chars - optimal length
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['seo_score'], 0)
|
||||
|
||||
def test_calculate_seo_score_with_primary_keyword(self):
|
||||
"""Test SEO score calculation with primary keyword"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
primary_keyword="test keyword",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['seo_score'], 0)
|
||||
|
||||
def test_calculate_readability_score(self):
|
||||
"""Test readability score calculation"""
|
||||
# Create content with good readability (short sentences, paragraphs)
|
||||
html = "<p>This is a sentence.</p><p>This is another sentence.</p><p>And one more.</p>"
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content=html,
|
||||
word_count=20,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['readability_score'], 0)
|
||||
|
||||
def test_calculate_engagement_score_with_headings(self):
|
||||
"""Test engagement score calculation with headings"""
|
||||
html = "<h1>Main Heading</h1><h2>Subheading 1</h2><h2>Subheading 2</h2>"
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content=html,
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['engagement_score'], 0)
|
||||
|
||||
def test_calculate_engagement_score_with_internal_links(self):
|
||||
"""Test engagement score calculation with internal links"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content="<p>Test content.</p>",
|
||||
internal_links=[
|
||||
{'content_id': 1, 'anchor_text': 'link1'},
|
||||
{'content_id': 2, 'anchor_text': 'link2'},
|
||||
{'content_id': 3, 'anchor_text': 'link3'}
|
||||
],
|
||||
word_count=100,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
self.assertGreater(scores['engagement_score'], 0)
|
||||
self.assertEqual(scores['internal_links_count'], 3)
|
||||
|
||||
def test_overall_score_is_weighted_average(self):
|
||||
"""Test that overall score is weighted average"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
html_content="<p>Test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test",
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
scores = self.analyzer.analyze(content)
|
||||
|
||||
# Overall should be weighted: SEO (40%) + Readability (30%) + Engagement (30%)
|
||||
expected = (
|
||||
scores['seo_score'] * 0.4 +
|
||||
scores['readability_score'] * 0.3 +
|
||||
scores['engagement_score'] * 0.3
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(scores['overall_score'], expected, places=1)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Tests for OptimizerService
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.optimization.models import OptimizationTask
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class OptimizerServiceTests(IntegrationTestBase):
|
||||
"""Tests for OptimizerService"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = OptimizerService()
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test keyword",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.OptimizerService._optimize_content')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.deduct_credits_for_operation')
|
||||
def test_optimize_from_writer(self, mock_deduct, mock_optimize, mock_analyze, mock_check):
|
||||
"""Test optimize_from_writer entry point"""
|
||||
mock_check.return_value = True
|
||||
mock_analyze.return_value = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
|
||||
optimized_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Optimized Content",
|
||||
html_content="<p>Optimized content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
result = self.service.optimize_from_writer(self.content.id)
|
||||
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
mock_check.assert_called_once()
|
||||
mock_deduct.assert_called_once()
|
||||
|
||||
def test_optimize_from_writer_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.optimize_from_writer(99999)
|
||||
|
||||
def test_optimize_from_writer_wrong_source(self):
|
||||
"""Test that ValueError is raised for wrong source"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
word_count=100,
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.optimize_from_writer(content.id)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
def test_optimize_insufficient_credits(self, mock_check):
|
||||
"""Test that InsufficientCreditsError is raised when credits are insufficient"""
|
||||
mock_check.side_effect = InsufficientCreditsError("Insufficient credits")
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
self.service.optimize(self.content)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.OptimizerService._optimize_content')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.deduct_credits_for_operation')
|
||||
def test_optimize_creates_optimization_task(self, mock_deduct, mock_optimize, mock_analyze, mock_check):
|
||||
"""Test that optimization creates OptimizationTask"""
|
||||
mock_check.return_value = True
|
||||
scores = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
mock_analyze.return_value = scores
|
||||
|
||||
optimized_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Optimized",
|
||||
html_content="<p>Optimized.</p>",
|
||||
word_count=500,
|
||||
status='draft'
|
||||
)
|
||||
mock_optimize.return_value = optimized_content
|
||||
|
||||
result = self.service.optimize(self.content)
|
||||
|
||||
# Check that task was created
|
||||
task = OptimizationTask.objects.filter(content=self.content).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task.status, 'completed')
|
||||
self.assertEqual(task.scores_before, scores)
|
||||
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.CreditService.check_credits')
|
||||
@patch('igny8_core.business.optimization.services.optimizer_service.ContentAnalyzer.analyze')
|
||||
def test_analyze_only_returns_scores(self, mock_analyze, mock_check):
|
||||
"""Test analyze_only method returns scores without optimizing"""
|
||||
scores = {
|
||||
'seo_score': 50.0,
|
||||
'readability_score': 60.0,
|
||||
'engagement_score': 55.0,
|
||||
'overall_score': 55.0
|
||||
}
|
||||
mock_analyze.return_value = scores
|
||||
|
||||
result = self.service.analyze_only(self.content.id)
|
||||
|
||||
self.assertEqual(result, scores)
|
||||
mock_analyze.assert_called_once()
|
||||
|
||||
def test_optimize_from_wordpress_sync(self):
|
||||
"""Test optimize_from_wordpress_sync entry point"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
word_count=100,
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = content
|
||||
result = self.service.optimize_from_wordpress_sync(content.id)
|
||||
|
||||
self.assertEqual(result.id, content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
def test_optimize_from_external_sync(self):
|
||||
"""Test optimize_from_external_sync entry point"""
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Shopify Content",
|
||||
word_count=100,
|
||||
source='shopify'
|
||||
)
|
||||
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = content
|
||||
result = self.service.optimize_from_external_sync(content.id)
|
||||
|
||||
self.assertEqual(result.id, content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
def test_optimize_manual(self):
|
||||
"""Test optimize_manual entry point"""
|
||||
with patch.object(self.service, 'optimize') as mock_optimize:
|
||||
mock_optimize.return_value = self.content
|
||||
result = self.service.optimize_manual(self.content.id)
|
||||
|
||||
self.assertEqual(result.id, self.content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user