Add source tracking and sync status fields to Content model; update services module
- Introduced new fields in the Content model for source tracking and sync status, including external references and optimization fields. - Updated the services module to include new content generation and pipeline services for better organization and clarity.
This commit is contained in:
Binary file not shown.
@@ -115,6 +115,47 @@ class Content(SiteSectorBaseModel):
|
|||||||
generated_at = models.DateTimeField(auto_now_add=True)
|
generated_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# Phase 4: Source tracking
|
||||||
|
SOURCE_CHOICES = [
|
||||||
|
('igny8', 'IGNY8 Generated'),
|
||||||
|
('wordpress', 'WordPress Synced'),
|
||||||
|
('shopify', 'Shopify Synced'),
|
||||||
|
('custom', 'Custom API Synced'),
|
||||||
|
]
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=SOURCE_CHOICES,
|
||||||
|
default='igny8',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Source of the content"
|
||||||
|
)
|
||||||
|
|
||||||
|
SYNC_STATUS_CHOICES = [
|
||||||
|
('native', 'Native IGNY8 Content'),
|
||||||
|
('imported', 'Imported from External'),
|
||||||
|
('synced', 'Synced from External'),
|
||||||
|
]
|
||||||
|
sync_status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=SYNC_STATUS_CHOICES,
|
||||||
|
default='native',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Sync status of the content"
|
||||||
|
)
|
||||||
|
|
||||||
|
# External reference fields
|
||||||
|
external_id = models.CharField(max_length=255, blank=True, null=True, help_text="External platform ID")
|
||||||
|
external_url = models.URLField(blank=True, null=True, help_text="External platform URL")
|
||||||
|
sync_metadata = models.JSONField(default=dict, blank=True, help_text="Platform-specific sync metadata")
|
||||||
|
|
||||||
|
# Phase 4: Linking fields
|
||||||
|
internal_links = models.JSONField(default=list, blank=True, help_text="Internal links added by linker")
|
||||||
|
linker_version = models.IntegerField(default=0, help_text="Version of linker processing")
|
||||||
|
|
||||||
|
# Phase 4: Optimization fields
|
||||||
|
optimizer_version = models.IntegerField(default=0, help_text="Version of optimizer processing")
|
||||||
|
optimization_scores = models.JSONField(default=dict, blank=True, help_text="Optimization scores (SEO, readability, engagement)")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'writer'
|
app_label = 'writer'
|
||||||
db_table = 'igny8_content'
|
db_table = 'igny8_content'
|
||||||
@@ -124,6 +165,9 @@ class Content(SiteSectorBaseModel):
|
|||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['task']),
|
models.Index(fields=['task']),
|
||||||
models.Index(fields=['generated_at']),
|
models.Index(fields=['generated_at']),
|
||||||
|
models.Index(fields=['source']),
|
||||||
|
models.Index(fields=['sync_status']),
|
||||||
|
models.Index(fields=['source', 'sync_status']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Content services
|
Content Services
|
||||||
"""
|
"""
|
||||||
|
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||||
|
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||||
|
|
||||||
|
__all__ = ['ContentGenerationService', 'ContentPipelineService']
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
Content Pipeline Service
|
||||||
|
Orchestrates content processing pipeline: Writer → Linker → Optimizer
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
from igny8_core.business.linking.services.linker_service import LinkerService
|
||||||
|
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentPipelineService:
|
||||||
|
"""Orchestrates content processing pipeline"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.linker_service = LinkerService()
|
||||||
|
self.optimizer_service = OptimizerService()
|
||||||
|
|
||||||
|
def process_writer_content(
|
||||||
|
self,
|
||||||
|
content_id: int,
|
||||||
|
stages: Optional[List[str]] = None
|
||||||
|
) -> Content:
|
||||||
|
"""
|
||||||
|
Writer → Linker → Optimizer pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID from Writer
|
||||||
|
stages: List of stages to run: ['linking', 'optimization'] (default: both)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed Content instance
|
||||||
|
"""
|
||||||
|
if stages is None:
|
||||||
|
stages = ['linking', 'optimization']
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id, source='igny8')
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"IGNY8 content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
# Stage 1: Linking
|
||||||
|
if 'linking' in stages:
|
||||||
|
try:
|
||||||
|
content = self.linker_service.process(content.id)
|
||||||
|
logger.info(f"Linked content {content_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in linking stage for content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
# Continue to next stage even if linking fails
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Stage 2: Optimization
|
||||||
|
if 'optimization' in stages:
|
||||||
|
try:
|
||||||
|
content = self.optimizer_service.optimize_from_writer(content.id)
|
||||||
|
logger.info(f"Optimized content {content_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in optimization stage for content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
# Don't fail the whole pipeline
|
||||||
|
pass
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def process_synced_content(
|
||||||
|
self,
|
||||||
|
content_id: int,
|
||||||
|
stages: Optional[List[str]] = None
|
||||||
|
) -> Content:
|
||||||
|
"""
|
||||||
|
Synced Content → Optimizer pipeline (skip linking if needed).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID from sync (WordPress, Shopify, etc.)
|
||||||
|
stages: List of stages to run: ['optimization'] (default: optimization only)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed Content instance
|
||||||
|
"""
|
||||||
|
if stages is None:
|
||||||
|
stages = ['optimization']
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"Content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
# Stage: Optimization (skip linking for synced content by default)
|
||||||
|
if 'optimization' in stages:
|
||||||
|
try:
|
||||||
|
if content.source == 'wordpress':
|
||||||
|
content = self.optimizer_service.optimize_from_wordpress_sync(content.id)
|
||||||
|
elif content.source in ['shopify', 'custom']:
|
||||||
|
content = self.optimizer_service.optimize_from_external_sync(content.id)
|
||||||
|
else:
|
||||||
|
content = self.optimizer_service.optimize_manual(content.id)
|
||||||
|
|
||||||
|
logger.info(f"Optimized synced content {content_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in optimization stage for content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def batch_process_writer_content(
|
||||||
|
self,
|
||||||
|
content_ids: List[int],
|
||||||
|
stages: Optional[List[str]] = None
|
||||||
|
) -> List[Content]:
|
||||||
|
"""
|
||||||
|
Batch process multiple Writer content items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_ids: List of content IDs
|
||||||
|
stages: List of stages to run
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of processed Content instances
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for content_id in content_ids:
|
||||||
|
try:
|
||||||
|
result = self.process_writer_content(content_id, stages)
|
||||||
|
results.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
# Continue with other items
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
6
backend/igny8_core/business/linking/__init__.py
Normal file
6
backend/igny8_core/business/linking/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Linking Business Logic
|
||||||
|
Phase 4: Linker & Optimizer
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
5
backend/igny8_core/business/linking/services/__init__.py
Normal file
5
backend/igny8_core/business/linking/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Linking Services
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
117
backend/igny8_core/business/linking/services/candidate_engine.py
Normal file
117
backend/igny8_core/business/linking/services/candidate_engine.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Link Candidate Engine
|
||||||
|
Finds relevant content for internal linking
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict
|
||||||
|
from django.db import models
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateEngine:
|
||||||
|
"""Finds link candidates for content"""
|
||||||
|
|
||||||
|
def find_candidates(self, content: Content, max_candidates: int = 10) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Find link candidates for a piece of content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance to find links for
|
||||||
|
max_candidates: Maximum number of candidates to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of candidate dicts with: {'content_id', 'title', 'url', 'relevance_score', 'anchor_text'}
|
||||||
|
"""
|
||||||
|
if not content or not content.html_content:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Find relevant content from same account/site/sector
|
||||||
|
relevant_content = self._find_relevant_content(content)
|
||||||
|
|
||||||
|
# Score candidates based on relevance
|
||||||
|
candidates = self._score_candidates(content, relevant_content)
|
||||||
|
|
||||||
|
# Sort by score and return top candidates
|
||||||
|
candidates.sort(key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||||
|
|
||||||
|
return candidates[:max_candidates]
|
||||||
|
|
||||||
|
def _find_relevant_content(self, content: Content) -> List[Content]:
|
||||||
|
"""Find relevant content from same account/site/sector"""
|
||||||
|
# Get content from same account, site, and sector
|
||||||
|
queryset = Content.objects.filter(
|
||||||
|
account=content.account,
|
||||||
|
site=content.site,
|
||||||
|
sector=content.sector,
|
||||||
|
status__in=['draft', 'review', 'publish']
|
||||||
|
).exclude(id=content.id)
|
||||||
|
|
||||||
|
# Filter by keywords if available
|
||||||
|
if content.primary_keyword:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
models.Q(primary_keyword__icontains=content.primary_keyword) |
|
||||||
|
models.Q(secondary_keywords__icontains=content.primary_keyword)
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(queryset[:50]) # Limit initial query
|
||||||
|
|
||||||
|
def _score_candidates(self, content: Content, candidates: List[Content]) -> List[Dict]:
|
||||||
|
"""Score candidates based on relevance"""
|
||||||
|
scored = []
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Keyword overlap (higher weight)
|
||||||
|
if content.primary_keyword and candidate.primary_keyword:
|
||||||
|
if content.primary_keyword.lower() in candidate.primary_keyword.lower():
|
||||||
|
score += 30
|
||||||
|
if candidate.primary_keyword.lower() in content.primary_keyword.lower():
|
||||||
|
score += 30
|
||||||
|
|
||||||
|
# Secondary keywords overlap
|
||||||
|
if content.secondary_keywords and candidate.secondary_keywords:
|
||||||
|
overlap = set(content.secondary_keywords) & set(candidate.secondary_keywords)
|
||||||
|
score += len(overlap) * 10
|
||||||
|
|
||||||
|
# Category overlap
|
||||||
|
if content.categories and candidate.categories:
|
||||||
|
overlap = set(content.categories) & set(candidate.categories)
|
||||||
|
score += len(overlap) * 5
|
||||||
|
|
||||||
|
# Tag overlap
|
||||||
|
if content.tags and candidate.tags:
|
||||||
|
overlap = set(content.tags) & set(candidate.tags)
|
||||||
|
score += len(overlap) * 3
|
||||||
|
|
||||||
|
# Recency bonus (newer content gets slight boost)
|
||||||
|
if candidate.generated_at:
|
||||||
|
days_old = (content.generated_at - candidate.generated_at).days
|
||||||
|
if days_old < 30:
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
if score > 0:
|
||||||
|
scored.append({
|
||||||
|
'content_id': candidate.id,
|
||||||
|
'title': candidate.title or candidate.task.title if candidate.task else 'Untitled',
|
||||||
|
'url': f"/content/{candidate.id}/", # Placeholder - actual URL depends on routing
|
||||||
|
'relevance_score': score,
|
||||||
|
'anchor_text': self._generate_anchor_text(candidate, content)
|
||||||
|
})
|
||||||
|
|
||||||
|
return scored
|
||||||
|
|
||||||
|
def _generate_anchor_text(self, candidate: Content, source_content: Content) -> str:
|
||||||
|
"""Generate anchor text for link"""
|
||||||
|
# Use primary keyword if available, otherwise use title
|
||||||
|
if candidate.primary_keyword:
|
||||||
|
return candidate.primary_keyword
|
||||||
|
elif candidate.title:
|
||||||
|
return candidate.title
|
||||||
|
elif candidate.task and candidate.task.title:
|
||||||
|
return candidate.task.title
|
||||||
|
else:
|
||||||
|
return "Learn more"
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Link Injection Engine
|
||||||
|
Injects internal links into content HTML
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import List, Dict
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InjectionEngine:
|
||||||
|
"""Injects links into content HTML"""
|
||||||
|
|
||||||
|
def inject_links(self, content: Content, candidates: List[Dict], max_links: int = 5) -> Dict:
|
||||||
|
"""
|
||||||
|
Inject links into content HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance
|
||||||
|
candidates: List of link candidates from CandidateEngine
|
||||||
|
max_links: Maximum number of links to inject
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with: {'html_content', 'links', 'links_added'}
|
||||||
|
"""
|
||||||
|
if not content.html_content or not candidates:
|
||||||
|
return {
|
||||||
|
'html_content': content.html_content,
|
||||||
|
'links': [],
|
||||||
|
'links_added': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
html = content.html_content
|
||||||
|
links_added = []
|
||||||
|
links_used = set() # Track which candidates we've used
|
||||||
|
|
||||||
|
# Sort candidates by relevance score
|
||||||
|
sorted_candidates = sorted(candidates, key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||||
|
|
||||||
|
# Inject links (limit to max_links)
|
||||||
|
for candidate in sorted_candidates[:max_links]:
|
||||||
|
if candidate['content_id'] in links_used:
|
||||||
|
continue
|
||||||
|
|
||||||
|
anchor_text = candidate.get('anchor_text', 'Learn more')
|
||||||
|
url = candidate.get('url', f"/content/{candidate['content_id']}/")
|
||||||
|
|
||||||
|
# Find first occurrence of anchor text in HTML (case-insensitive)
|
||||||
|
pattern = re.compile(re.escape(anchor_text), re.IGNORECASE)
|
||||||
|
match = pattern.search(html)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
# Replace with link
|
||||||
|
link_html = f'<a href="{url}" class="internal-link">{anchor_text}</a>'
|
||||||
|
html = html[:match.start()] + link_html + html[match.end():]
|
||||||
|
|
||||||
|
links_added.append({
|
||||||
|
'content_id': candidate['content_id'],
|
||||||
|
'anchor_text': anchor_text,
|
||||||
|
'url': url,
|
||||||
|
'position': match.start()
|
||||||
|
})
|
||||||
|
links_used.add(candidate['content_id'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'html_content': html,
|
||||||
|
'links': links_added,
|
||||||
|
'links_added': len(links_added)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
101
backend/igny8_core/business/linking/services/linker_service.py
Normal file
101
backend/igny8_core/business/linking/services/linker_service.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Linker Service
|
||||||
|
Main service for processing content for internal linking
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
from igny8_core.business.linking.services.candidate_engine import CandidateEngine
|
||||||
|
from igny8_core.business.linking.services.injection_engine import InjectionEngine
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkerService:
|
||||||
|
"""Service for processing content for internal linking"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.candidate_engine = CandidateEngine()
|
||||||
|
self.injection_engine = InjectionEngine()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def process(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Process content for linking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Content instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"Content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
account = content.account
|
||||||
|
|
||||||
|
# Check credits
|
||||||
|
try:
|
||||||
|
self.credit_service.check_credits(account, 'linking')
|
||||||
|
except InsufficientCreditsError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Find link candidates
|
||||||
|
candidates = self.candidate_engine.find_candidates(content)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
logger.info(f"No link candidates found for content {content_id}")
|
||||||
|
return content
|
||||||
|
|
||||||
|
# Inject links
|
||||||
|
result = self.injection_engine.inject_links(content, candidates)
|
||||||
|
|
||||||
|
# Update content
|
||||||
|
content.html_content = result['html_content']
|
||||||
|
content.internal_links = result['links']
|
||||||
|
content.linker_version += 1
|
||||||
|
content.save(update_fields=['html_content', 'internal_links', 'linker_version'])
|
||||||
|
|
||||||
|
# Deduct credits
|
||||||
|
self.credit_service.deduct_credits_for_operation(
|
||||||
|
account=account,
|
||||||
|
operation_type='linking',
|
||||||
|
description=f"Internal linking for content: {content.title or 'Untitled'}",
|
||||||
|
related_object_type='content',
|
||||||
|
related_object_id=content.id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Linked content {content_id}: {result['links_added']} links added")
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def batch_process(self, content_ids: List[int]) -> List[Content]:
|
||||||
|
"""
|
||||||
|
Process multiple content items for linking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_ids: List of content IDs to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of updated Content instances
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for content_id in content_ids:
|
||||||
|
try:
|
||||||
|
result = self.process(content_id)
|
||||||
|
results.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing content {content_id}: {str(e)}", exc_info=True)
|
||||||
|
# Continue with other items
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
6
backend/igny8_core/business/optimization/__init__.py
Normal file
6
backend/igny8_core/business/optimization/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Optimization Business Logic
|
||||||
|
Phase 4: Linker & Optimizer
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
77
backend/igny8_core/business/optimization/models.py
Normal file
77
backend/igny8_core/business/optimization/models.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Optimization Models
|
||||||
|
Phase 4: Linker & Optimizer
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from igny8_core.auth.models import AccountBaseModel
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationTask(AccountBaseModel):
|
||||||
|
"""
|
||||||
|
Optimization Task model for tracking content optimization runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('running', 'Running'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
content = models.ForeignKey(
|
||||||
|
Content,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='optimization_tasks',
|
||||||
|
help_text="The content being optimized"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scores before and after optimization
|
||||||
|
scores_before = models.JSONField(default=dict, help_text="Optimization scores before")
|
||||||
|
scores_after = models.JSONField(default=dict, help_text="Optimization scores after")
|
||||||
|
|
||||||
|
# Content before and after (for comparison)
|
||||||
|
html_before = models.TextField(blank=True, help_text="HTML content before optimization")
|
||||||
|
html_after = models.TextField(blank=True, help_text="HTML content after optimization")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='pending',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Optimization task status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Credits used
|
||||||
|
credits_used = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Credits used for optimization")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'optimization'
|
||||||
|
db_table = 'igny8_optimization_tasks'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Optimization Task'
|
||||||
|
verbose_name_plural = 'Optimization Tasks'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['content', 'status']),
|
||||||
|
models.Index(fields=['account', 'status']),
|
||||||
|
models.Index(fields=['status', 'created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Automatically set account from content"""
|
||||||
|
if self.content:
|
||||||
|
self.account = self.content.account
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Optimization for {self.content.title or 'Content'} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Optimization Services
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
184
backend/igny8_core/business/optimization/services/analyzer.py
Normal file
184
backend/igny8_core/business/optimization/services/analyzer.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Content Analyzer
|
||||||
|
Analyzes content quality and calculates optimization scores
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Dict
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentAnalyzer:
|
||||||
|
"""Analyzes content quality"""
|
||||||
|
|
||||||
|
def analyze(self, content: Content) -> Dict:
|
||||||
|
"""
|
||||||
|
Analyze content and return scores.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance to analyze
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with scores: {'seo_score', 'readability_score', 'engagement_score', 'overall_score'}
|
||||||
|
"""
|
||||||
|
if not content or not content.html_content:
|
||||||
|
return {
|
||||||
|
'seo_score': 0,
|
||||||
|
'readability_score': 0,
|
||||||
|
'engagement_score': 0,
|
||||||
|
'overall_score': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
seo_score = self._calculate_seo_score(content)
|
||||||
|
readability_score = self._calculate_readability_score(content)
|
||||||
|
engagement_score = self._calculate_engagement_score(content)
|
||||||
|
|
||||||
|
# Overall score is weighted average
|
||||||
|
overall_score = (
|
||||||
|
seo_score * 0.4 +
|
||||||
|
readability_score * 0.3 +
|
||||||
|
engagement_score * 0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'seo_score': round(seo_score, 2),
|
||||||
|
'readability_score': round(readability_score, 2),
|
||||||
|
'engagement_score': round(engagement_score, 2),
|
||||||
|
'overall_score': round(overall_score, 2),
|
||||||
|
'word_count': content.word_count or 0,
|
||||||
|
'has_meta_title': bool(content.meta_title),
|
||||||
|
'has_meta_description': bool(content.meta_description),
|
||||||
|
'has_primary_keyword': bool(content.primary_keyword),
|
||||||
|
'internal_links_count': len(content.internal_links) if content.internal_links else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_seo_score(self, content: Content) -> float:
|
||||||
|
"""Calculate SEO score (0-100)"""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Meta title (20 points)
|
||||||
|
if content.meta_title:
|
||||||
|
if len(content.meta_title) >= 30 and len(content.meta_title) <= 60:
|
||||||
|
score += 20
|
||||||
|
elif len(content.meta_title) > 0:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Meta description (20 points)
|
||||||
|
if content.meta_description:
|
||||||
|
if len(content.meta_description) >= 120 and len(content.meta_description) <= 160:
|
||||||
|
score += 20
|
||||||
|
elif len(content.meta_description) > 0:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Primary keyword (20 points)
|
||||||
|
if content.primary_keyword:
|
||||||
|
score += 20
|
||||||
|
|
||||||
|
# Word count (20 points) - optimal range 1000-2500 words
|
||||||
|
word_count = content.word_count or 0
|
||||||
|
if 1000 <= word_count <= 2500:
|
||||||
|
score += 20
|
||||||
|
elif 500 <= word_count < 1000 or 2500 < word_count <= 3000:
|
||||||
|
score += 15
|
||||||
|
elif word_count > 0:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Internal links (20 points)
|
||||||
|
internal_links = content.internal_links or []
|
||||||
|
if len(internal_links) >= 3:
|
||||||
|
score += 20
|
||||||
|
elif len(internal_links) >= 1:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
return min(score, 100)
|
||||||
|
|
||||||
|
def _calculate_readability_score(self, content: Content) -> float:
|
||||||
|
"""Calculate readability score (0-100)"""
|
||||||
|
if not content.html_content:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Simple readability metrics
|
||||||
|
html = content.html_content
|
||||||
|
|
||||||
|
# Remove HTML tags for text analysis
|
||||||
|
text = re.sub(r'<[^>]+>', '', html)
|
||||||
|
sentences = re.split(r'[.!?]+', text)
|
||||||
|
words = text.split()
|
||||||
|
|
||||||
|
if not words:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Average sentence length (optimal: 15-20 words)
|
||||||
|
avg_sentence_length = len(words) / max(len(sentences), 1)
|
||||||
|
if 15 <= avg_sentence_length <= 20:
|
||||||
|
sentence_score = 40
|
||||||
|
elif 10 <= avg_sentence_length < 15 or 20 < avg_sentence_length <= 25:
|
||||||
|
sentence_score = 30
|
||||||
|
else:
|
||||||
|
sentence_score = 20
|
||||||
|
|
||||||
|
# Average word length (optimal: 4-5 characters)
|
||||||
|
avg_word_length = sum(len(word) for word in words) / len(words)
|
||||||
|
if 4 <= avg_word_length <= 5:
|
||||||
|
word_score = 30
|
||||||
|
elif 3 <= avg_word_length < 4 or 5 < avg_word_length <= 6:
|
||||||
|
word_score = 20
|
||||||
|
else:
|
||||||
|
word_score = 10
|
||||||
|
|
||||||
|
# Paragraph structure (30 points)
|
||||||
|
paragraphs = html.count('<p>') + html.count('<div>')
|
||||||
|
if paragraphs >= 3:
|
||||||
|
paragraph_score = 30
|
||||||
|
elif paragraphs >= 1:
|
||||||
|
paragraph_score = 20
|
||||||
|
else:
|
||||||
|
paragraph_score = 10
|
||||||
|
|
||||||
|
return min(sentence_score + word_score + paragraph_score, 100)
|
||||||
|
|
||||||
|
def _calculate_engagement_score(self, content: Content) -> float:
|
||||||
|
"""Calculate engagement score (0-100)"""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# Headings (30 points)
|
||||||
|
if content.html_content:
|
||||||
|
h1_count = content.html_content.count('<h1>')
|
||||||
|
h2_count = content.html_content.count('<h2>')
|
||||||
|
h3_count = content.html_content.count('<h3>')
|
||||||
|
|
||||||
|
if h1_count >= 1 and h2_count >= 2:
|
||||||
|
score += 30
|
||||||
|
elif h1_count >= 1 or h2_count >= 1:
|
||||||
|
score += 20
|
||||||
|
elif h3_count >= 1:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Images (30 points)
|
||||||
|
if hasattr(content, 'images'):
|
||||||
|
image_count = content.images.count()
|
||||||
|
if image_count >= 3:
|
||||||
|
score += 30
|
||||||
|
elif image_count >= 1:
|
||||||
|
score += 20
|
||||||
|
|
||||||
|
# Lists (20 points)
|
||||||
|
if content.html_content:
|
||||||
|
list_count = content.html_content.count('<ul>') + content.html_content.count('<ol>')
|
||||||
|
if list_count >= 2:
|
||||||
|
score += 20
|
||||||
|
elif list_count >= 1:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Internal links (20 points)
|
||||||
|
internal_links = content.internal_links or []
|
||||||
|
if len(internal_links) >= 3:
|
||||||
|
score += 20
|
||||||
|
elif len(internal_links) >= 1:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
return min(score, 100)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Optimizer Service
|
||||||
|
Main service for content optimization with multiple entry points
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from igny8_core.business.content.models import Content
|
||||||
|
from igny8_core.business.optimization.models import OptimizationTask
|
||||||
|
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||||
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizerService:
|
||||||
|
"""Service for content optimization with multiple entry points"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.analyzer = ContentAnalyzer()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def optimize_from_writer(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Entry Point 1: Writer → Optimizer
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID from Writer module
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id, source='igny8')
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"IGNY8 content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.optimize(content)
|
||||||
|
|
||||||
|
def optimize_from_wordpress_sync(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Entry Point 2: WordPress Sync → Optimizer
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID synced from WordPress
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id, source='wordpress')
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"WordPress content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.optimize(content)
|
||||||
|
|
||||||
|
def optimize_from_external_sync(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Entry Point 3: External Sync → Optimizer (Shopify, custom APIs)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID synced from external source
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id, source__in=['shopify', 'custom'])
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"External content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.optimize(content)
|
||||||
|
|
||||||
|
def optimize_manual(self, content_id: int) -> Content:
|
||||||
|
"""
|
||||||
|
Entry Point 4: Manual Selection → Optimizer
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID selected manually
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"Content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.optimize(content)
|
||||||
|
|
||||||
|
def optimize(self, content: Content) -> Content:
|
||||||
|
"""
|
||||||
|
Unified optimization logic (used by all entry points).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content instance to optimize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized Content instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
|
"""
|
||||||
|
account = content.account
|
||||||
|
word_count = content.word_count or 0
|
||||||
|
|
||||||
|
# Check credits
|
||||||
|
try:
|
||||||
|
self.credit_service.check_credits(account, 'optimization', word_count)
|
||||||
|
except InsufficientCreditsError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Analyze content before optimization
|
||||||
|
scores_before = self.analyzer.analyze(content)
|
||||||
|
html_before = content.html_content
|
||||||
|
|
||||||
|
# Create optimization task
|
||||||
|
task = OptimizationTask.objects.create(
|
||||||
|
content=content,
|
||||||
|
scores_before=scores_before,
|
||||||
|
status='running',
|
||||||
|
html_before=html_before,
|
||||||
|
account=account
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delegate to AI function (actual optimization happens in Celery/AI task)
|
||||||
|
# For now, we'll do a simple optimization pass
|
||||||
|
# In production, this would call the AI function
|
||||||
|
optimized_content = self._optimize_content(content, scores_before)
|
||||||
|
|
||||||
|
# Analyze optimized content
|
||||||
|
scores_after = self.analyzer.analyze(optimized_content)
|
||||||
|
|
||||||
|
# Calculate credits used
|
||||||
|
credits_used = self.credit_service.get_credit_cost('optimization', word_count)
|
||||||
|
|
||||||
|
# Update optimization task
|
||||||
|
task.scores_after = scores_after
|
||||||
|
task.html_after = optimized_content.html_content
|
||||||
|
task.status = 'completed'
|
||||||
|
task.credits_used = credits_used
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# Update content
|
||||||
|
content.html_content = optimized_content.html_content
|
||||||
|
content.optimizer_version += 1
|
||||||
|
content.optimization_scores = scores_after
|
||||||
|
content.save(update_fields=['html_content', 'optimizer_version', 'optimization_scores'])
|
||||||
|
|
||||||
|
# Deduct credits
|
||||||
|
self.credit_service.deduct_credits_for_operation(
|
||||||
|
account=account,
|
||||||
|
operation_type='optimization',
|
||||||
|
amount=word_count,
|
||||||
|
description=f"Content optimization: {content.title or 'Untitled'}",
|
||||||
|
related_object_type='content',
|
||||||
|
related_object_id=content.id,
|
||||||
|
metadata={
|
||||||
|
'scores_before': scores_before,
|
||||||
|
'scores_after': scores_after,
|
||||||
|
'improvement': scores_after.get('overall_score', 0) - scores_before.get('overall_score', 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Optimized content {content.id}: {scores_before.get('overall_score', 0)} → {scores_after.get('overall_score', 0)}")
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error optimizing content {content.id}: {str(e)}", exc_info=True)
|
||||||
|
task.status = 'failed'
|
||||||
|
task.metadata = {'error': str(e)}
|
||||||
|
task.save()
|
||||||
|
raise
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Content to optimize
|
||||||
|
scores_before: Scores before optimization
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Placeholder: We'll implement AI function call later
|
||||||
|
# For now, just return the content
|
||||||
|
return content
|
||||||
|
|
||||||
|
def analyze_only(self, content_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Analyze content without optimizing (for preview).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_id: Content ID to analyze
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Analysis scores dict
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
except Content.DoesNotExist:
|
||||||
|
raise ValueError(f"Content with id {content_id} does not exist")
|
||||||
|
|
||||||
|
return self.analyzer.analyze(content)
|
||||||
|
|
||||||
|
|
||||||
6
backend/igny8_core/business/site_building/__init__.py
Normal file
6
backend/igny8_core/business/site_building/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Site Building Business Logic
|
||||||
|
Phase 3: Site Builder
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
168
backend/igny8_core/business/site_building/models.py
Normal file
168
backend/igny8_core/business/site_building/models.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Site Builder Models
|
||||||
|
Phase 3: Site Builder
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from igny8_core.auth.models import SiteSectorBaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SiteBlueprint(SiteSectorBaseModel):
|
||||||
|
"""
|
||||||
|
Site Blueprint model for storing AI-generated site structures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('generating', 'Generating'),
|
||||||
|
('ready', 'Ready'),
|
||||||
|
('deployed', 'Deployed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
HOSTING_TYPE_CHOICES = [
|
||||||
|
('igny8_sites', 'IGNY8 Sites'),
|
||||||
|
('wordpress', 'WordPress'),
|
||||||
|
('shopify', 'Shopify'),
|
||||||
|
('multi', 'Multiple Destinations'),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255, help_text="Site name")
|
||||||
|
description = models.TextField(blank=True, null=True, help_text="Site description")
|
||||||
|
|
||||||
|
# Site configuration (from wizard)
|
||||||
|
config_json = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
help_text="Wizard configuration: business_type, style, objectives, etc."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generated structure (from AI)
|
||||||
|
structure_json = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
help_text="AI-generated structure: pages, layout, theme, etc."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status tracking
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='draft',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Blueprint status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hosting configuration
|
||||||
|
hosting_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=HOSTING_TYPE_CHOICES,
|
||||||
|
default='igny8_sites',
|
||||||
|
help_text="Target hosting platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Version tracking
|
||||||
|
version = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Blueprint version")
|
||||||
|
deployed_version = models.IntegerField(null=True, blank=True, help_text="Currently deployed version")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'site_building'
|
||||||
|
db_table = 'igny8_site_blueprints'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Site Blueprint'
|
||||||
|
verbose_name_plural = 'Site Blueprints'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['hosting_type']),
|
||||||
|
models.Index(fields=['site', 'sector']),
|
||||||
|
models.Index(fields=['account', 'status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
|
||||||
|
class PageBlueprint(SiteSectorBaseModel):
|
||||||
|
"""
|
||||||
|
Page Blueprint model for storing individual page definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAGE_TYPE_CHOICES = [
|
||||||
|
('home', 'Home'),
|
||||||
|
('about', 'About'),
|
||||||
|
('services', 'Services'),
|
||||||
|
('products', 'Products'),
|
||||||
|
('blog', 'Blog'),
|
||||||
|
('contact', 'Contact'),
|
||||||
|
('custom', 'Custom'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('generating', 'Generating'),
|
||||||
|
('ready', 'Ready'),
|
||||||
|
]
|
||||||
|
|
||||||
|
site_blueprint = models.ForeignKey(
|
||||||
|
SiteBlueprint,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='pages',
|
||||||
|
help_text="The site blueprint this page belongs to"
|
||||||
|
)
|
||||||
|
slug = models.SlugField(max_length=255, help_text="Page URL slug")
|
||||||
|
title = models.CharField(max_length=255, help_text="Page title")
|
||||||
|
|
||||||
|
# Page type
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=PAGE_TYPE_CHOICES,
|
||||||
|
default='custom',
|
||||||
|
help_text="Page type"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Page content (blocks)
|
||||||
|
blocks_json = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="Page content blocks: [{'type': 'hero', 'data': {...}}, ...]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='draft',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Page status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order
|
||||||
|
order = models.IntegerField(default=0, help_text="Page order in navigation")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'site_building'
|
||||||
|
db_table = 'igny8_page_blueprints'
|
||||||
|
ordering = ['order', 'created_at']
|
||||||
|
verbose_name = 'Page Blueprint'
|
||||||
|
verbose_name_plural = 'Page Blueprints'
|
||||||
|
unique_together = [['site_blueprint', 'slug']]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['site_blueprint', 'status']),
|
||||||
|
models.Index(fields=['type']),
|
||||||
|
models.Index(fields=['site_blueprint', 'order']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Automatically set account, site, and sector from site_blueprint"""
|
||||||
|
if self.site_blueprint:
|
||||||
|
self.account = self.site_blueprint.account
|
||||||
|
self.site = self.site_blueprint.site
|
||||||
|
self.sector = self.site_blueprint.sector
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} ({self.site_blueprint.name})"
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Site Building Services
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
Site File Management Service
|
||||||
|
Manages file uploads, deletions, and access control for site assets
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
|
from igny8_core.auth.models import User, Site
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Base path for site files
|
||||||
|
SITES_DATA_BASE = Path('/data/app/sites-data/clients')
|
||||||
|
|
||||||
|
|
||||||
|
class SiteBuilderFileService:
|
||||||
|
"""Service for managing site files and assets"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_path = SITES_DATA_BASE
|
||||||
|
self.max_file_size = 10 * 1024 * 1024 # 10MB per file
|
||||||
|
self.max_storage_per_site = 100 * 1024 * 1024 # 100MB per site
|
||||||
|
|
||||||
|
def get_user_accessible_sites(self, user: User) -> List[Site]:
|
||||||
|
"""
|
||||||
|
Get sites user can access for file management.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Site instances user can access
|
||||||
|
"""
|
||||||
|
# Owner/Admin: Full access to all account sites
|
||||||
|
if user.is_owner_or_admin():
|
||||||
|
return Site.objects.filter(account=user.account, is_active=True)
|
||||||
|
|
||||||
|
# Editor/Viewer: Access to granted sites (via SiteUserAccess)
|
||||||
|
# TODO: Implement SiteUserAccess check when available
|
||||||
|
return Site.objects.filter(account=user.account, is_active=True)
|
||||||
|
|
||||||
|
def check_file_access(self, user: User, site_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user can access site's files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
site_id: Site ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has access, False otherwise
|
||||||
|
"""
|
||||||
|
accessible_sites = self.get_user_accessible_sites(user)
|
||||||
|
return any(site.id == site_id for site in accessible_sites)
|
||||||
|
|
||||||
|
def get_site_files_path(self, site_id: int, version: int = 1) -> Path:
|
||||||
|
"""
|
||||||
|
Get site's files directory path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID
|
||||||
|
version: Site version (default: 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path object for site files directory
|
||||||
|
"""
|
||||||
|
return self.base_path / str(site_id) / f"v{version}" / "assets"
|
||||||
|
|
||||||
|
def check_storage_quota(self, site_id: int, file_size: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if site has enough storage quota.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID
|
||||||
|
file_size: Size of file to upload in bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if quota available, False otherwise
|
||||||
|
"""
|
||||||
|
site_path = self.get_site_files_path(site_id)
|
||||||
|
|
||||||
|
# Calculate current storage usage
|
||||||
|
current_usage = self._calculate_storage_usage(site_path)
|
||||||
|
|
||||||
|
# Check if adding file would exceed quota
|
||||||
|
return (current_usage + file_size) <= self.max_storage_per_site
|
||||||
|
|
||||||
|
def _calculate_storage_usage(self, site_path: Path) -> int:
|
||||||
|
"""Calculate current storage usage for a site"""
|
||||||
|
if not site_path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for file_path in site_path.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
total_size += file_path.stat().st_size
|
||||||
|
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
def upload_file(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
site_id: int,
|
||||||
|
file,
|
||||||
|
folder: str = 'images',
|
||||||
|
version: int = 1
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Upload file to site's assets folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
site_id: Site ID
|
||||||
|
file: Django UploadedFile instance
|
||||||
|
folder: Subfolder name (images, documents, media)
|
||||||
|
version: Site version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with file_path, file_url, file_size
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionDenied: If user doesn't have access
|
||||||
|
ValidationError: If file size exceeds limit or quota exceeded
|
||||||
|
"""
|
||||||
|
# Check access
|
||||||
|
if not self.check_file_access(user, site_id):
|
||||||
|
raise PermissionDenied("No access to this site")
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
if file.size > self.max_file_size:
|
||||||
|
raise ValidationError(f"File size exceeds maximum of {self.max_file_size / 1024 / 1024}MB")
|
||||||
|
|
||||||
|
# Check storage quota
|
||||||
|
if not self.check_storage_quota(site_id, file.size):
|
||||||
|
raise ValidationError("Storage quota exceeded")
|
||||||
|
|
||||||
|
# Get target directory
|
||||||
|
site_path = self.get_site_files_path(site_id, version)
|
||||||
|
target_dir = site_path / folder
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
file_path = target_dir / file.name
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
for chunk in file.chunks():
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
# Generate file URL (relative to site assets)
|
||||||
|
file_url = f"/sites/{site_id}/v{version}/assets/{folder}/{file.name}"
|
||||||
|
|
||||||
|
logger.info(f"Uploaded file {file.name} to site {site_id}/{folder}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'file_path': str(file_path),
|
||||||
|
'file_url': file_url,
|
||||||
|
'file_size': file.size,
|
||||||
|
'folder': folder
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_file(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
site_id: int,
|
||||||
|
file_path: str,
|
||||||
|
version: int = 1
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete file from site's assets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
site_id: Site ID
|
||||||
|
file_path: Relative file path (e.g., 'images/photo.jpg')
|
||||||
|
version: Site version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False otherwise
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionDenied: If user doesn't have access
|
||||||
|
"""
|
||||||
|
# Check access
|
||||||
|
if not self.check_file_access(user, site_id):
|
||||||
|
raise PermissionDenied("No access to this site")
|
||||||
|
|
||||||
|
# Get full file path
|
||||||
|
site_path = self.get_site_files_path(site_id, version)
|
||||||
|
full_path = site_path / file_path
|
||||||
|
|
||||||
|
# Check if file exists and is within site directory
|
||||||
|
if not full_path.exists() or not str(full_path).startswith(str(site_path)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete file
|
||||||
|
full_path.unlink()
|
||||||
|
|
||||||
|
logger.info(f"Deleted file {file_path} from site {site_id}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_files(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
site_id: int,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
version: int = 1
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
List files in site's assets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
site_id: Site ID
|
||||||
|
folder: Optional folder to list (None = all folders)
|
||||||
|
version: Site version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of file dicts with: name, path, size, folder, url
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionDenied: If user doesn't have access
|
||||||
|
"""
|
||||||
|
# Check access
|
||||||
|
if not self.check_file_access(user, site_id):
|
||||||
|
raise PermissionDenied("No access to this site")
|
||||||
|
|
||||||
|
site_path = self.get_site_files_path(site_id, version)
|
||||||
|
|
||||||
|
if not site_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
files = []
|
||||||
|
|
||||||
|
# List files in specified folder or all folders
|
||||||
|
if folder:
|
||||||
|
folder_path = site_path / folder
|
||||||
|
if folder_path.exists():
|
||||||
|
files.extend(self._list_directory(folder_path, folder, site_id, version))
|
||||||
|
else:
|
||||||
|
# List all folders
|
||||||
|
for folder_dir in site_path.iterdir():
|
||||||
|
if folder_dir.is_dir():
|
||||||
|
files.extend(self._list_directory(folder_dir, folder_dir.name, site_id, version))
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def _list_directory(self, directory: Path, folder_name: str, site_id: int, version: int) -> List[Dict]:
|
||||||
|
"""List files in a directory"""
|
||||||
|
files = []
|
||||||
|
for file_path in directory.iterdir():
|
||||||
|
if file_path.is_file():
|
||||||
|
file_url = f"/sites/{site_id}/v{version}/assets/{folder_name}/{file_path.name}"
|
||||||
|
files.append({
|
||||||
|
'name': file_path.name,
|
||||||
|
'path': f"{folder_name}/{file_path.name}",
|
||||||
|
'size': file_path.stat().st_size,
|
||||||
|
'folder': folder_name,
|
||||||
|
'url': file_url
|
||||||
|
})
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
866
docs/planning/PHASE-3-4-IMPLEMENTATION-PLAN.md
Normal file
866
docs/planning/PHASE-3-4-IMPLEMENTATION-PLAN.md
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
# PHASE 3 & 4 IMPLEMENTATION PLAN
|
||||||
|
**Detailed Configuration Plan for Site Builder & Linker/Optimizer**
|
||||||
|
|
||||||
|
**Created**: 2025-01-XX
|
||||||
|
**Status**: Planning Phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TABLE OF CONTENTS
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Phase 3: Site Builder Implementation Plan](#phase-3-site-builder-implementation-plan)
|
||||||
|
3. [Phase 4: Linker & Optimizer Implementation Plan](#phase-4-linker--optimizer-implementation-plan)
|
||||||
|
4. [Integration Points](#integration-points)
|
||||||
|
5. [File Structure](#file-structure)
|
||||||
|
6. [Dependencies & Order](#dependencies--order)
|
||||||
|
7. [Testing Strategy](#testing-strategy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
### Implementation Approach
|
||||||
|
- **Phase 3**: Build Site Builder with wizard, AI structure generation, and file management
|
||||||
|
- **Phase 4**: Implement Linker and Optimizer as post-processing stages with multiple entry points
|
||||||
|
- **Shared Components**: Create global component library for reuse across apps
|
||||||
|
- **Integration**: Ensure seamless integration with existing Phase 1 & 2 services
|
||||||
|
|
||||||
|
### Key Principles
|
||||||
|
- **Service Layer Pattern**: All business logic in services (Phase 1 pattern)
|
||||||
|
- **Credit-Aware**: All operations check credits before execution
|
||||||
|
- **Multiple Entry Points**: Optimizer works from Writer, WordPress sync, 3rd party, manual
|
||||||
|
- **Component Reuse**: Shared components across Site Builder, Sites Renderer, Main App
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3: SITE BUILDER IMPLEMENTATION PLAN
|
||||||
|
|
||||||
|
### 3.1 Backend Structure
|
||||||
|
|
||||||
|
#### Business Layer (`business/site_building/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
business/site_building/
|
||||||
|
├── __init__.py
|
||||||
|
├── models.py # SiteBlueprint, PageBlueprint
|
||||||
|
├── migrations/
|
||||||
|
│ └── 0001_initial.py
|
||||||
|
└── services/
|
||||||
|
├── __init__.py
|
||||||
|
├── file_management_service.py
|
||||||
|
├── structure_generation_service.py
|
||||||
|
└── page_generation_service.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Models to Create**:
|
||||||
|
|
||||||
|
1. **SiteBlueprint** (`business/site_building/models.py`)
|
||||||
|
- Fields:
|
||||||
|
- `name`, `description`
|
||||||
|
- `config_json` (wizard choices: business_type, style, objectives)
|
||||||
|
- `structure_json` (AI-generated structure: pages, layout, theme)
|
||||||
|
- `status` (draft, generating, ready, deployed)
|
||||||
|
- `hosting_type` (igny8_sites, wordpress, shopify, multi)
|
||||||
|
- `version`, `deployed_version`
|
||||||
|
- Inherits from `SiteSectorBaseModel`
|
||||||
|
|
||||||
|
2. **PageBlueprint** (`business/site_building/models.py`)
|
||||||
|
- Fields:
|
||||||
|
- `site_blueprint` (ForeignKey)
|
||||||
|
- `slug`, `title`
|
||||||
|
- `type` (home, about, services, products, blog, contact, custom)
|
||||||
|
- `blocks_json` (page content blocks)
|
||||||
|
- `status` (draft, generating, ready)
|
||||||
|
- `order`
|
||||||
|
- Inherits from `SiteSectorBaseModel`
|
||||||
|
|
||||||
|
#### Services to Create
|
||||||
|
|
||||||
|
1. **FileManagementService** (`business/site_building/services/file_management_service.py`)
|
||||||
|
```python
|
||||||
|
class SiteBuilderFileService:
|
||||||
|
def get_user_accessible_sites(self, user)
|
||||||
|
def check_file_access(self, user, site_id)
|
||||||
|
def upload_file(self, user, site_id, file, folder='images')
|
||||||
|
def delete_file(self, user, site_id, file_path)
|
||||||
|
def list_files(self, user, site_id, folder='images')
|
||||||
|
def check_storage_quota(self, site_id, file_size)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **StructureGenerationService** (`business/site_building/services/structure_generation_service.py`)
|
||||||
|
```python
|
||||||
|
class StructureGenerationService:
|
||||||
|
def __init__(self):
|
||||||
|
self.ai_function = GenerateSiteStructureFunction()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def generate_structure(self, site_blueprint, business_brief, objectives, style)
|
||||||
|
def _create_page_blueprints(self, site_blueprint, structure)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **PageGenerationService** (`business/site_building/services/page_generation_service.py`)
|
||||||
|
```python
|
||||||
|
class PageGenerationService:
|
||||||
|
def __init__(self):
|
||||||
|
self.content_service = ContentGenerationService()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def generate_page_content(self, page_blueprint, account)
|
||||||
|
def regenerate_page(self, page_blueprint, account)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AI Functions (`infrastructure/ai/functions/`)
|
||||||
|
|
||||||
|
1. **GenerateSiteStructureFunction** (`infrastructure/ai/functions/generate_site_structure.py`)
|
||||||
|
- Operation type: `site_structure_generation`
|
||||||
|
- Credit cost: 50 credits (from constants)
|
||||||
|
- Generates site structure JSON from business brief
|
||||||
|
|
||||||
|
#### API Layer (`modules/site_builder/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/site_builder/
|
||||||
|
├── __init__.py
|
||||||
|
├── views.py # SiteBuilderViewSet, PageBlueprintViewSet, FileUploadView
|
||||||
|
├── serializers.py # SiteBlueprintSerializer, PageBlueprintSerializer
|
||||||
|
├── urls.py
|
||||||
|
└── apps.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**ViewSets to Create**:
|
||||||
|
|
||||||
|
1. **SiteBuilderViewSet** (`modules/site_builder/views.py`)
|
||||||
|
- CRUD for SiteBlueprint
|
||||||
|
- Actions:
|
||||||
|
- `generate_structure/` (POST) - Trigger AI structure generation
|
||||||
|
- `deploy/` (POST) - Deploy site to hosting
|
||||||
|
- `preview/` (GET) - Get preview JSON
|
||||||
|
|
||||||
|
2. **PageBlueprintViewSet** (`modules/site_builder/views.py`)
|
||||||
|
- CRUD for PageBlueprint
|
||||||
|
- Actions:
|
||||||
|
- `generate_content/` (POST) - Generate page content
|
||||||
|
- `regenerate/` (POST) - Regenerate page content
|
||||||
|
|
||||||
|
3. **FileUploadView** (`modules/site_builder/views.py`)
|
||||||
|
- `upload/` (POST) - Upload file to site assets
|
||||||
|
- `delete/` (DELETE) - Delete file
|
||||||
|
- `list/` (GET) - List files
|
||||||
|
|
||||||
|
#### File Storage Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/app/sites-data/
|
||||||
|
└── clients/
|
||||||
|
└── {site_id}/
|
||||||
|
└── v{version}/
|
||||||
|
├── site.json # Site definition
|
||||||
|
├── pages/ # Page definitions
|
||||||
|
│ ├── home.json
|
||||||
|
│ ├── about.json
|
||||||
|
│ └── ...
|
||||||
|
└── assets/ # User-managed files
|
||||||
|
├── images/
|
||||||
|
├── documents/
|
||||||
|
└── media/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Frontend Structure
|
||||||
|
|
||||||
|
#### Site Builder Container (`site-builder/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
site-builder/
|
||||||
|
├── src/
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── wizard/
|
||||||
|
│ │ │ ├── Step1TypeSelection.tsx
|
||||||
|
│ │ │ ├── Step2BusinessBrief.tsx
|
||||||
|
│ │ │ ├── Step3Objectives.tsx
|
||||||
|
│ │ │ └── Step4Style.tsx
|
||||||
|
│ │ ├── preview/
|
||||||
|
│ │ │ └── PreviewCanvas.tsx
|
||||||
|
│ │ └── dashboard/
|
||||||
|
│ │ └── SiteList.tsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── blocks/ # Block components (import from shared)
|
||||||
|
│ │ ├── forms/
|
||||||
|
│ │ ├── files/
|
||||||
|
│ │ │ └── FileBrowser.tsx
|
||||||
|
│ │ └── preview-canvas/
|
||||||
|
│ ├── state/
|
||||||
|
│ │ ├── builderStore.ts
|
||||||
|
│ │ └── siteDefinitionStore.ts
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── builder.api.ts
|
||||||
|
│ │ └── sites.api.ts
|
||||||
|
│ └── main.tsx
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.ts
|
||||||
|
└── Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared Component Library (`frontend/src/components/shared/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/components/shared/
|
||||||
|
├── blocks/
|
||||||
|
│ ├── Hero.tsx
|
||||||
|
│ ├── Features.tsx
|
||||||
|
│ ├── Services.tsx
|
||||||
|
│ ├── Products.tsx
|
||||||
|
│ ├── Testimonials.tsx
|
||||||
|
│ ├── ContactForm.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── layouts/
|
||||||
|
│ ├── DefaultLayout.tsx
|
||||||
|
│ ├── MinimalLayout.tsx
|
||||||
|
│ ├── MagazineLayout.tsx
|
||||||
|
│ ├── EcommerceLayout.tsx
|
||||||
|
│ ├── PortfolioLayout.tsx
|
||||||
|
│ ├── BlogLayout.tsx
|
||||||
|
│ └── CorporateLayout.tsx
|
||||||
|
└── templates/
|
||||||
|
├── BlogTemplate.tsx
|
||||||
|
├── BusinessTemplate.tsx
|
||||||
|
└── PortfolioTemplate.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Implementation Tasks
|
||||||
|
|
||||||
|
#### Backend Tasks (Priority Order)
|
||||||
|
|
||||||
|
1. **Create Business Models**
|
||||||
|
- [ ] Create `business/site_building/` folder
|
||||||
|
- [ ] Create `SiteBlueprint` model
|
||||||
|
- [ ] Create `PageBlueprint` model
|
||||||
|
- [ ] Create migrations
|
||||||
|
|
||||||
|
2. **Create Services**
|
||||||
|
- [ ] Create `FileManagementService`
|
||||||
|
- [ ] Create `StructureGenerationService`
|
||||||
|
- [ ] Create `PageGenerationService`
|
||||||
|
- [ ] Integrate with `CreditService`
|
||||||
|
|
||||||
|
3. **Create AI Function**
|
||||||
|
- [ ] Create `GenerateSiteStructureFunction`
|
||||||
|
- [ ] Add prompts for site structure generation
|
||||||
|
- [ ] Test AI function
|
||||||
|
|
||||||
|
4. **Create API Layer**
|
||||||
|
- [ ] Create `modules/site_builder/` folder
|
||||||
|
- [ ] Create `SiteBuilderViewSet`
|
||||||
|
- [ ] Create `PageBlueprintViewSet`
|
||||||
|
- [ ] Create `FileUploadView`
|
||||||
|
- [ ] Create serializers
|
||||||
|
- [ ] Register URLs
|
||||||
|
|
||||||
|
#### Frontend Tasks (Priority Order)
|
||||||
|
|
||||||
|
1. **Create Site Builder Container**
|
||||||
|
- [ ] Create `site-builder/` folder structure
|
||||||
|
- [ ] Set up Vite + React + TypeScript
|
||||||
|
- [ ] Configure Docker container
|
||||||
|
- [ ] Set up routing
|
||||||
|
|
||||||
|
2. **Create Wizard**
|
||||||
|
- [ ] Step 1: Type Selection
|
||||||
|
- [ ] Step 2: Business Brief
|
||||||
|
- [ ] Step 3: Objectives
|
||||||
|
- [ ] Step 4: Style Preferences
|
||||||
|
- [ ] Wizard state management
|
||||||
|
|
||||||
|
3. **Create Preview Canvas**
|
||||||
|
- [ ] Preview renderer
|
||||||
|
- [ ] Block rendering
|
||||||
|
- [ ] Layout rendering
|
||||||
|
|
||||||
|
4. **Create Shared Components**
|
||||||
|
- [ ] Block components
|
||||||
|
- [ ] Layout components
|
||||||
|
- [ ] Template components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 4: LINKER & OPTIMIZER IMPLEMENTATION PLAN
|
||||||
|
|
||||||
|
### 4.1 Backend Structure
|
||||||
|
|
||||||
|
#### Business Layer
|
||||||
|
|
||||||
|
```
|
||||||
|
business/
|
||||||
|
├── linking/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── models.py # InternalLink (optional)
|
||||||
|
│ └── services/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── linker_service.py
|
||||||
|
│ ├── candidate_engine.py
|
||||||
|
│ └── injection_engine.py
|
||||||
|
│
|
||||||
|
├── optimization/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── models.py # OptimizationTask, OptimizationScores
|
||||||
|
│ └── services/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── optimizer_service.py
|
||||||
|
│ └── analyzer.py
|
||||||
|
│
|
||||||
|
└── content/
|
||||||
|
└── services/
|
||||||
|
└── content_pipeline_service.py # NEW: Orchestrates pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Content Model Extensions
|
||||||
|
|
||||||
|
**Extend `business/content/models.py`**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Content(SiteSectorBaseModel):
|
||||||
|
# Existing fields...
|
||||||
|
|
||||||
|
# NEW: Source tracking (Phase 4)
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('igny8', 'IGNY8 Generated'),
|
||||||
|
('wordpress', 'WordPress Synced'),
|
||||||
|
('shopify', 'Shopify Synced'),
|
||||||
|
('custom', 'Custom API Synced'),
|
||||||
|
],
|
||||||
|
default='igny8'
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('native', 'Native IGNY8 Content'),
|
||||||
|
('imported', 'Imported from External'),
|
||||||
|
('synced', 'Synced from External'),
|
||||||
|
],
|
||||||
|
default='native'
|
||||||
|
)
|
||||||
|
|
||||||
|
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
external_url = models.URLField(blank=True, null=True)
|
||||||
|
sync_metadata = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
# NEW: Linking fields
|
||||||
|
internal_links = models.JSONField(default=list)
|
||||||
|
linker_version = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
# NEW: Optimization fields
|
||||||
|
optimizer_version = models.IntegerField(default=0)
|
||||||
|
optimization_scores = models.JSONField(default=dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Models to Create
|
||||||
|
|
||||||
|
1. **OptimizationTask** (`business/optimization/models.py`)
|
||||||
|
- Fields:
|
||||||
|
- `content` (ForeignKey to Content)
|
||||||
|
- `scores_before`, `scores_after` (JSON)
|
||||||
|
- `html_before`, `html_after` (Text)
|
||||||
|
- `status` (pending, completed, failed)
|
||||||
|
- `credits_used`
|
||||||
|
- Inherits from `AccountBaseModel`
|
||||||
|
|
||||||
|
2. **OptimizationScores** (`business/optimization/models.py`) - Optional
|
||||||
|
- Store detailed scoring metrics
|
||||||
|
|
||||||
|
#### Services to Create
|
||||||
|
|
||||||
|
1. **LinkerService** (`business/linking/services/linker_service.py`)
|
||||||
|
```python
|
||||||
|
class LinkerService:
|
||||||
|
def __init__(self):
|
||||||
|
self.candidate_engine = CandidateEngine()
|
||||||
|
self.injection_engine = InjectionEngine()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
def process(self, content_id)
|
||||||
|
def batch_process(self, content_ids)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **CandidateEngine** (`business/linking/services/candidate_engine.py`)
|
||||||
|
```python
|
||||||
|
class CandidateEngine:
|
||||||
|
def find_candidates(self, content)
|
||||||
|
def _find_relevant_content(self, content)
|
||||||
|
def _score_candidates(self, content, candidates)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **InjectionEngine** (`business/linking/services/injection_engine.py`)
|
||||||
|
```python
|
||||||
|
class InjectionEngine:
|
||||||
|
def inject_links(self, content, candidates)
|
||||||
|
def _inject_link_into_html(self, html, link_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **OptimizerService** (`business/optimization/services/optimizer_service.py`)
|
||||||
|
```python
|
||||||
|
class OptimizerService:
|
||||||
|
def __init__(self):
|
||||||
|
self.analyzer = ContentAnalyzer()
|
||||||
|
self.ai_function = OptimizeContentFunction()
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
|
||||||
|
# Multiple entry points
|
||||||
|
def optimize_from_writer(self, content_id)
|
||||||
|
def optimize_from_wordpress_sync(self, content_id)
|
||||||
|
def optimize_from_external_sync(self, content_id)
|
||||||
|
def optimize_manual(self, content_id)
|
||||||
|
|
||||||
|
# Unified optimization logic
|
||||||
|
def optimize(self, content)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **ContentAnalyzer** (`business/optimization/services/analyzer.py`)
|
||||||
|
```python
|
||||||
|
class ContentAnalyzer:
|
||||||
|
def analyze(self, content)
|
||||||
|
def _calculate_seo_score(self, content)
|
||||||
|
def _calculate_readability_score(self, content)
|
||||||
|
def _calculate_engagement_score(self, content)
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **ContentPipelineService** (`business/content/services/content_pipeline_service.py`)
|
||||||
|
```python
|
||||||
|
class ContentPipelineService:
|
||||||
|
def __init__(self):
|
||||||
|
self.linker_service = LinkerService()
|
||||||
|
self.optimizer_service = OptimizerService()
|
||||||
|
|
||||||
|
def process_writer_content(self, content_id, stages=['linking', 'optimization'])
|
||||||
|
def process_synced_content(self, content_id, stages=['optimization'])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AI Functions
|
||||||
|
|
||||||
|
1. **OptimizeContentFunction** (`infrastructure/ai/functions/optimize_content.py`)
|
||||||
|
- Operation type: `optimization`
|
||||||
|
- Credit cost: 1 credit per 200 words
|
||||||
|
- Optimizes content for SEO, readability, engagement
|
||||||
|
|
||||||
|
#### API Layer (`modules/linker/` and `modules/optimizer/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/
|
||||||
|
├── linker/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── views.py # LinkerViewSet
|
||||||
|
│ ├── serializers.py
|
||||||
|
│ ├── urls.py
|
||||||
|
│ └── apps.py
|
||||||
|
│
|
||||||
|
└── optimizer/
|
||||||
|
├── __init__.py
|
||||||
|
├── views.py # OptimizerViewSet
|
||||||
|
├── serializers.py
|
||||||
|
├── urls.py
|
||||||
|
└── apps.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**ViewSets to Create**:
|
||||||
|
|
||||||
|
1. **LinkerViewSet** (`modules/linker/views.py`)
|
||||||
|
- Actions:
|
||||||
|
- `process/` (POST) - Process content for linking
|
||||||
|
- `batch_process/` (POST) - Process multiple content items
|
||||||
|
|
||||||
|
2. **OptimizerViewSet** (`modules/optimizer/views.py`)
|
||||||
|
- Actions:
|
||||||
|
- `optimize/` (POST) - Optimize content (auto-detects source)
|
||||||
|
- `optimize_from_writer/` (POST) - Entry point 1
|
||||||
|
- `optimize_from_sync/` (POST) - Entry point 2 & 3
|
||||||
|
- `optimize_manual/` (POST) - Entry point 4
|
||||||
|
- `analyze/` (GET) - Analyze content without optimizing
|
||||||
|
|
||||||
|
### 4.2 Frontend Structure
|
||||||
|
|
||||||
|
#### Linker UI (`frontend/src/pages/Linker/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/pages/Linker/
|
||||||
|
├── Dashboard.tsx
|
||||||
|
├── ContentList.tsx
|
||||||
|
└── LinkResults.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Optimizer UI (`frontend/src/pages/Optimizer/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/pages/Optimizer/
|
||||||
|
├── Dashboard.tsx
|
||||||
|
├── ContentSelector.tsx
|
||||||
|
├── OptimizationResults.tsx
|
||||||
|
└── ScoreComparison.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared Components
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/components/
|
||||||
|
├── content/
|
||||||
|
│ ├── SourceBadge.tsx # Show content source (IGNY8, WordPress, etc.)
|
||||||
|
│ ├── SyncStatusBadge.tsx # Show sync status
|
||||||
|
│ ├── ContentFilter.tsx # Filter by source, sync_status
|
||||||
|
│ └── SourceFilter.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Implementation Tasks
|
||||||
|
|
||||||
|
#### Backend Tasks (Priority Order)
|
||||||
|
|
||||||
|
1. **Extend Content Model**
|
||||||
|
- [ ] Add `source` field
|
||||||
|
- [ ] Add `sync_status` field
|
||||||
|
- [ ] Add `external_id`, `external_url`, `sync_metadata`
|
||||||
|
- [ ] Add `internal_links`, `linker_version`
|
||||||
|
- [ ] Add `optimizer_version`, `optimization_scores`
|
||||||
|
- [ ] Create migration
|
||||||
|
|
||||||
|
2. **Create Linking Services**
|
||||||
|
- [ ] Create `business/linking/` folder
|
||||||
|
- [ ] Create `LinkerService`
|
||||||
|
- [ ] Create `CandidateEngine`
|
||||||
|
- [ ] Create `InjectionEngine`
|
||||||
|
- [ ] Integrate with `CreditService`
|
||||||
|
|
||||||
|
3. **Create Optimization Services**
|
||||||
|
- [ ] Create `business/optimization/` folder
|
||||||
|
- [ ] Create `OptimizationTask` model
|
||||||
|
- [ ] Create `OptimizerService` (with multiple entry points)
|
||||||
|
- [ ] Create `ContentAnalyzer`
|
||||||
|
- [ ] Integrate with `CreditService`
|
||||||
|
|
||||||
|
4. **Create AI Function**
|
||||||
|
- [ ] Create `OptimizeContentFunction`
|
||||||
|
- [ ] Add optimization prompts
|
||||||
|
- [ ] Test AI function
|
||||||
|
|
||||||
|
5. **Create Pipeline Service**
|
||||||
|
- [ ] Create `ContentPipelineService`
|
||||||
|
- [ ] Integrate Linker and Optimizer
|
||||||
|
|
||||||
|
6. **Create API Layer**
|
||||||
|
- [ ] Create `modules/linker/` folder
|
||||||
|
- [ ] Create `LinkerViewSet`
|
||||||
|
- [ ] Create `modules/optimizer/` folder
|
||||||
|
- [ ] Create `OptimizerViewSet`
|
||||||
|
- [ ] Create serializers
|
||||||
|
- [ ] Register URLs
|
||||||
|
|
||||||
|
#### Frontend Tasks (Priority Order)
|
||||||
|
|
||||||
|
1. **Create Linker UI**
|
||||||
|
- [ ] Linker Dashboard
|
||||||
|
- [ ] Content List
|
||||||
|
- [ ] Link Results display
|
||||||
|
|
||||||
|
2. **Create Optimizer UI**
|
||||||
|
- [ ] Optimizer Dashboard
|
||||||
|
- [ ] Content Selector (with source filters)
|
||||||
|
- [ ] Optimization Results
|
||||||
|
- [ ] Score Comparison
|
||||||
|
|
||||||
|
3. **Create Shared Components**
|
||||||
|
- [ ] SourceBadge component
|
||||||
|
- [ ] SyncStatusBadge component
|
||||||
|
- [ ] ContentFilter component
|
||||||
|
- [ ] SourceFilter component
|
||||||
|
|
||||||
|
4. **Update Content List**
|
||||||
|
- [ ] Add source badges
|
||||||
|
- [ ] Add sync status badges
|
||||||
|
- [ ] Add filters (by source, sync_status)
|
||||||
|
- [ ] Add "Send to Optimizer" button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## INTEGRATION POINTS
|
||||||
|
|
||||||
|
### Phase 3 Integration
|
||||||
|
|
||||||
|
1. **With Phase 1 Services**
|
||||||
|
- `StructureGenerationService` uses `CreditService`
|
||||||
|
- `PageGenerationService` uses `ContentGenerationService`
|
||||||
|
- All operations check credits before execution
|
||||||
|
|
||||||
|
2. **With Phase 2 Automation**
|
||||||
|
- Automation rules can trigger site structure generation
|
||||||
|
- Automation can deploy sites automatically
|
||||||
|
|
||||||
|
3. **With Content Service**
|
||||||
|
- Page generation reuses `ContentGenerationService`
|
||||||
|
- Site pages stored as `Content` records
|
||||||
|
|
||||||
|
### Phase 4 Integration
|
||||||
|
|
||||||
|
1. **With Phase 1 Services**
|
||||||
|
- `LinkerService` uses `CreditService`
|
||||||
|
- `OptimizerService` uses `CreditService`
|
||||||
|
- `ContentPipelineService` orchestrates services
|
||||||
|
|
||||||
|
2. **With Writer Module**
|
||||||
|
- Writer → Linker → Optimizer pipeline
|
||||||
|
- Content generated in Writer flows to Linker/Optimizer
|
||||||
|
|
||||||
|
3. **With WordPress Sync** (Phase 6)
|
||||||
|
- WordPress content synced with `source='wordpress'`
|
||||||
|
- Optimizer works on synced content
|
||||||
|
|
||||||
|
4. **With 3rd Party Sync** (Phase 6)
|
||||||
|
- External content synced with `source='shopify'` or `source='custom'`
|
||||||
|
- Optimizer works on all sources
|
||||||
|
|
||||||
|
### Cross-Phase Integration
|
||||||
|
|
||||||
|
1. **Site Builder → Linker/Optimizer**
|
||||||
|
- Site pages can be optimized
|
||||||
|
- Site content can be linked internally
|
||||||
|
|
||||||
|
2. **Content Pipeline**
|
||||||
|
- Unified pipeline: Writer → Linker → Optimizer → Publish
|
||||||
|
- Works for all content sources
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FILE STRUCTURE
|
||||||
|
|
||||||
|
### Complete Backend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/igny8_core/
|
||||||
|
├── business/
|
||||||
|
│ ├── automation/ # Phase 2 ✅
|
||||||
|
│ ├── billing/ # Phase 0, 1 ✅
|
||||||
|
│ ├── content/ # Phase 1 ✅
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ └── content_pipeline_service.py # Phase 4 NEW
|
||||||
|
│ ├── linking/ # Phase 4 NEW
|
||||||
|
│ │ ├── models.py
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ ├── linker_service.py
|
||||||
|
│ │ ├── candidate_engine.py
|
||||||
|
│ │ └── injection_engine.py
|
||||||
|
│ ├── optimization/ # Phase 4 NEW
|
||||||
|
│ │ ├── models.py
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ ├── optimizer_service.py
|
||||||
|
│ │ └── analyzer.py
|
||||||
|
│ ├── planning/ # Phase 1 ✅
|
||||||
|
│ └── site_building/ # Phase 3 NEW
|
||||||
|
│ ├── models.py
|
||||||
|
│ └── services/
|
||||||
|
│ ├── file_management_service.py
|
||||||
|
│ ├── structure_generation_service.py
|
||||||
|
│ └── page_generation_service.py
|
||||||
|
│
|
||||||
|
├── modules/
|
||||||
|
│ ├── automation/ # Phase 2 ✅
|
||||||
|
│ ├── billing/ # Phase 0, 1 ✅
|
||||||
|
│ ├── linker/ # Phase 4 NEW
|
||||||
|
│ ├── optimizer/ # Phase 4 NEW
|
||||||
|
│ ├── planner/ # Phase 1 ✅
|
||||||
|
│ ├── site_builder/ # Phase 3 NEW
|
||||||
|
│ └── writer/ # Phase 1 ✅
|
||||||
|
│
|
||||||
|
└── infrastructure/
|
||||||
|
└── ai/
|
||||||
|
└── functions/
|
||||||
|
├── generate_site_structure.py # Phase 3 NEW
|
||||||
|
└── optimize_content.py # Phase 4 NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Frontend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── components/
|
||||||
|
│ ├── shared/ # Phase 3 NEW
|
||||||
|
│ │ ├── blocks/
|
||||||
|
│ │ ├── layouts/
|
||||||
|
│ │ └── templates/
|
||||||
|
│ └── content/ # Phase 4 NEW
|
||||||
|
│ ├── SourceBadge.tsx
|
||||||
|
│ ├── SyncStatusBadge.tsx
|
||||||
|
│ └── ContentFilter.tsx
|
||||||
|
│
|
||||||
|
└── pages/
|
||||||
|
├── Linker/ # Phase 4 NEW
|
||||||
|
│ ├── Dashboard.tsx
|
||||||
|
│ └── ContentList.tsx
|
||||||
|
├── Optimizer/ # Phase 4 NEW
|
||||||
|
│ ├── Dashboard.tsx
|
||||||
|
│ └── ContentSelector.tsx
|
||||||
|
└── Writer/ # Phase 1 ✅
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
site-builder/src/ # Phase 3 NEW
|
||||||
|
├── pages/
|
||||||
|
│ ├── wizard/
|
||||||
|
│ ├── preview/
|
||||||
|
│ └── dashboard/
|
||||||
|
└── components/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DEPENDENCIES & ORDER
|
||||||
|
|
||||||
|
### Phase 3 Dependencies
|
||||||
|
|
||||||
|
1. **Required (Already Complete)**
|
||||||
|
- ✅ Phase 0: Credit system
|
||||||
|
- ✅ Phase 1: Service layer (ContentGenerationService, CreditService)
|
||||||
|
- ✅ Phase 2: Automation system (optional integration)
|
||||||
|
|
||||||
|
2. **Phase 3 Implementation Order**
|
||||||
|
- Step 1: Create models (SiteBlueprint, PageBlueprint)
|
||||||
|
- Step 2: Create FileManagementService
|
||||||
|
- Step 3: Create StructureGenerationService + AI function
|
||||||
|
- Step 4: Create PageGenerationService
|
||||||
|
- Step 5: Create API layer (ViewSets)
|
||||||
|
- Step 6: Create frontend container structure
|
||||||
|
- Step 7: Create wizard UI
|
||||||
|
- Step 8: Create preview canvas
|
||||||
|
- Step 9: Create shared component library
|
||||||
|
|
||||||
|
### Phase 4 Dependencies
|
||||||
|
|
||||||
|
1. **Required (Already Complete)**
|
||||||
|
- ✅ Phase 0: Credit system
|
||||||
|
- ✅ Phase 1: Service layer (ContentGenerationService, CreditService)
|
||||||
|
- ✅ Content model (needs extension)
|
||||||
|
|
||||||
|
2. **Phase 4 Implementation Order**
|
||||||
|
- Step 1: Extend Content model (add source, sync_status, linking, optimization fields)
|
||||||
|
- Step 2: Create linking services (LinkerService, CandidateEngine, InjectionEngine)
|
||||||
|
- Step 3: Create optimization services (OptimizerService, ContentAnalyzer)
|
||||||
|
- Step 4: Create optimization AI function
|
||||||
|
- Step 5: Create ContentPipelineService
|
||||||
|
- Step 6: Create API layer (LinkerViewSet, OptimizerViewSet)
|
||||||
|
- Step 7: Create frontend UI (Linker Dashboard, Optimizer Dashboard)
|
||||||
|
- Step 8: Create shared components (SourceBadge, ContentFilter)
|
||||||
|
|
||||||
|
### Parallel Implementation
|
||||||
|
|
||||||
|
- **Phase 3 and Phase 4 can be implemented in parallel** after:
|
||||||
|
- Content model extensions (Phase 4 Step 1) are complete
|
||||||
|
- Both phases use Phase 1 services independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TESTING STRATEGY
|
||||||
|
|
||||||
|
### Phase 3 Testing
|
||||||
|
|
||||||
|
1. **Backend Tests**
|
||||||
|
- Test SiteBlueprint CRUD
|
||||||
|
- Test PageBlueprint CRUD
|
||||||
|
- Test structure generation (AI function)
|
||||||
|
- Test file upload/delete/access
|
||||||
|
- Test credit deduction
|
||||||
|
|
||||||
|
2. **Frontend Tests**
|
||||||
|
- Test wizard flow
|
||||||
|
- Test preview rendering
|
||||||
|
- Test file browser
|
||||||
|
- Test component library
|
||||||
|
|
||||||
|
### Phase 4 Testing
|
||||||
|
|
||||||
|
1. **Backend Tests**
|
||||||
|
- Test Content model extensions
|
||||||
|
- Test LinkerService (find candidates, inject links)
|
||||||
|
- Test OptimizerService (all entry points)
|
||||||
|
- Test ContentPipelineService
|
||||||
|
- Test credit deduction
|
||||||
|
|
||||||
|
2. **Frontend Tests**
|
||||||
|
- Test Linker UI
|
||||||
|
- Test Optimizer UI
|
||||||
|
- Test source filtering
|
||||||
|
- Test content selection
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
1. **Writer → Linker → Optimizer Pipeline**
|
||||||
|
- Test full pipeline flow
|
||||||
|
- Test credit deduction at each stage
|
||||||
|
|
||||||
|
2. **WordPress Sync → Optimizer** (Phase 6)
|
||||||
|
- Test synced content optimization
|
||||||
|
- Test source tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CREDIT COSTS
|
||||||
|
|
||||||
|
### Phase 3 Credit Costs
|
||||||
|
|
||||||
|
- `site_structure_generation`: 50 credits (per site blueprint)
|
||||||
|
- `site_page_generation`: 20 credits (per page)
|
||||||
|
- File storage: No credits (storage quota based)
|
||||||
|
|
||||||
|
### Phase 4 Credit Costs
|
||||||
|
|
||||||
|
- `linking`: 8 credits (per content piece)
|
||||||
|
- `optimization`: 1 credit per 200 words
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SUCCESS CRITERIA
|
||||||
|
|
||||||
|
### Phase 3 Success Criteria
|
||||||
|
|
||||||
|
- ✅ Site Builder wizard works end-to-end
|
||||||
|
- ✅ AI structure generation creates valid blueprints
|
||||||
|
- ✅ Preview renders correctly
|
||||||
|
- ✅ File management works
|
||||||
|
- ✅ Shared components work across apps
|
||||||
|
- ✅ Page generation reuses ContentGenerationService
|
||||||
|
|
||||||
|
### Phase 4 Success Criteria
|
||||||
|
|
||||||
|
- ✅ Linker finds appropriate link candidates
|
||||||
|
- ✅ Links inject correctly into content
|
||||||
|
- ✅ Optimizer works from all entry points (Writer, WordPress, 3rd party, Manual)
|
||||||
|
- ✅ Content source tracking works
|
||||||
|
- ✅ Pipeline orchestrates correctly
|
||||||
|
- ✅ UI shows content sources and filters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RISK MITIGATION
|
||||||
|
|
||||||
|
### Phase 3 Risks
|
||||||
|
|
||||||
|
1. **AI Structure Generation Quality**
|
||||||
|
- Mitigation: Prompt engineering, validation, user feedback loop
|
||||||
|
|
||||||
|
2. **Component Compatibility**
|
||||||
|
- Mitigation: Shared component library, comprehensive testing
|
||||||
|
|
||||||
|
3. **File Management Security**
|
||||||
|
- Mitigation: Access control, validation, quota checks
|
||||||
|
|
||||||
|
### Phase 4 Risks
|
||||||
|
|
||||||
|
1. **Link Quality**
|
||||||
|
- Mitigation: Candidate scoring algorithm, relevance checks
|
||||||
|
|
||||||
|
2. **Optimization Quality**
|
||||||
|
- Mitigation: Content analysis, before/after comparison, user review
|
||||||
|
|
||||||
|
3. **Multiple Entry Points Complexity**
|
||||||
|
- Mitigation: Unified optimization logic, clear entry point methods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**END OF IMPLEMENTATION PLAN**
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user