From 9930728e8a64ac7215964b3f171e63ba1e3d427c Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 17 Nov 2025 11:15:15 +0000 Subject: [PATCH] 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. --- backend/celerybeat-schedule | Bin 16384 -> 16384 bytes backend/igny8_core/business/content/models.py | 44 + .../business/content/services/__init__.py | 6 +- .../services/content_pipeline_service.py | 133 +++ .../igny8_core/business/linking/__init__.py | 6 + .../business/linking/services/__init__.py | 5 + .../linking/services/candidate_engine.py | 117 +++ .../linking/services/injection_engine.py | 73 ++ .../linking/services/linker_service.py | 101 ++ .../business/optimization/__init__.py | 6 + .../business/optimization/models.py | 77 ++ .../optimization/services/__init__.py | 5 + .../optimization/services/analyzer.py | 184 ++++ .../services/optimizer_service.py | 216 +++++ .../business/site_building/__init__.py | 6 + .../business/site_building/models.py | 168 ++++ .../site_building/services/__init__.py | 5 + .../services/file_management_service.py | 264 ++++++ .../planning/PHASE-3-4-IMPLEMENTATION-PLAN.md | 866 ++++++++++++++++++ 19 files changed, 2281 insertions(+), 1 deletion(-) create mode 100644 backend/igny8_core/business/content/services/content_pipeline_service.py create mode 100644 backend/igny8_core/business/linking/__init__.py create mode 100644 backend/igny8_core/business/linking/services/__init__.py create mode 100644 backend/igny8_core/business/linking/services/candidate_engine.py create mode 100644 backend/igny8_core/business/linking/services/injection_engine.py create mode 100644 backend/igny8_core/business/linking/services/linker_service.py create mode 100644 backend/igny8_core/business/optimization/__init__.py create mode 100644 backend/igny8_core/business/optimization/models.py create mode 100644 backend/igny8_core/business/optimization/services/__init__.py create mode 100644 backend/igny8_core/business/optimization/services/analyzer.py create mode 100644 backend/igny8_core/business/optimization/services/optimizer_service.py create mode 100644 backend/igny8_core/business/site_building/__init__.py create mode 100644 backend/igny8_core/business/site_building/models.py create mode 100644 backend/igny8_core/business/site_building/services/__init__.py create mode 100644 backend/igny8_core/business/site_building/services/file_management_service.py create mode 100644 docs/planning/PHASE-3-4-IMPLEMENTATION-PLAN.md diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule index e64e49c81f5a3fd656bd8f1a235ea3f037ed246b..40479f04bcc32c84e919d1ddec8e15f5dc66698f 100644 GIT binary patch delta 35 rcmZo@U~Fh$+>l}_Ajr+dz`)=-B}24rO3)PVX_FI-l{Zf}&Ex_Axu^>C delta 35 rcmZo@U~Fh$+>l}_ARuPWz`&3?B}24rO3)N<=E(`h%A2Q~W^w@lyF?1O diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py index 4688701c..035ef744 100644 --- a/backend/igny8_core/business/content/models.py +++ b/backend/igny8_core/business/content/models.py @@ -115,6 +115,47 @@ class Content(SiteSectorBaseModel): generated_at = models.DateTimeField(auto_now_add=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: app_label = 'writer' db_table = 'igny8_content' @@ -124,6 +165,9 @@ class Content(SiteSectorBaseModel): indexes = [ models.Index(fields=['task']), 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): diff --git a/backend/igny8_core/business/content/services/__init__.py b/backend/igny8_core/business/content/services/__init__.py index ba016ecd..50904818 100644 --- a/backend/igny8_core/business/content/services/__init__.py +++ b/backend/igny8_core/business/content/services/__init__.py @@ -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'] diff --git a/backend/igny8_core/business/content/services/content_pipeline_service.py b/backend/igny8_core/business/content/services/content_pipeline_service.py new file mode 100644 index 00000000..a6d324e2 --- /dev/null +++ b/backend/igny8_core/business/content/services/content_pipeline_service.py @@ -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 + + diff --git a/backend/igny8_core/business/linking/__init__.py b/backend/igny8_core/business/linking/__init__.py new file mode 100644 index 00000000..20c23aff --- /dev/null +++ b/backend/igny8_core/business/linking/__init__.py @@ -0,0 +1,6 @@ +""" +Linking Business Logic +Phase 4: Linker & Optimizer +""" + + diff --git a/backend/igny8_core/business/linking/services/__init__.py b/backend/igny8_core/business/linking/services/__init__.py new file mode 100644 index 00000000..44015889 --- /dev/null +++ b/backend/igny8_core/business/linking/services/__init__.py @@ -0,0 +1,5 @@ +""" +Linking Services +""" + + diff --git a/backend/igny8_core/business/linking/services/candidate_engine.py b/backend/igny8_core/business/linking/services/candidate_engine.py new file mode 100644 index 00000000..d821ac3c --- /dev/null +++ b/backend/igny8_core/business/linking/services/candidate_engine.py @@ -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" + diff --git a/backend/igny8_core/business/linking/services/injection_engine.py b/backend/igny8_core/business/linking/services/injection_engine.py new file mode 100644 index 00000000..13175ed5 --- /dev/null +++ b/backend/igny8_core/business/linking/services/injection_engine.py @@ -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'{anchor_text}' + 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) + } + + diff --git a/backend/igny8_core/business/linking/services/linker_service.py b/backend/igny8_core/business/linking/services/linker_service.py new file mode 100644 index 00000000..b709e6f4 --- /dev/null +++ b/backend/igny8_core/business/linking/services/linker_service.py @@ -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 + + diff --git a/backend/igny8_core/business/optimization/__init__.py b/backend/igny8_core/business/optimization/__init__.py new file mode 100644 index 00000000..e1ba5e4e --- /dev/null +++ b/backend/igny8_core/business/optimization/__init__.py @@ -0,0 +1,6 @@ +""" +Optimization Business Logic +Phase 4: Linker & Optimizer +""" + + diff --git a/backend/igny8_core/business/optimization/models.py b/backend/igny8_core/business/optimization/models.py new file mode 100644 index 00000000..62e87489 --- /dev/null +++ b/backend/igny8_core/business/optimization/models.py @@ -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()})" + + diff --git a/backend/igny8_core/business/optimization/services/__init__.py b/backend/igny8_core/business/optimization/services/__init__.py new file mode 100644 index 00000000..ddeb852b --- /dev/null +++ b/backend/igny8_core/business/optimization/services/__init__.py @@ -0,0 +1,5 @@ +""" +Optimization Services +""" + + diff --git a/backend/igny8_core/business/optimization/services/analyzer.py b/backend/igny8_core/business/optimization/services/analyzer.py new file mode 100644 index 00000000..ff1f0d08 --- /dev/null +++ b/backend/igny8_core/business/optimization/services/analyzer.py @@ -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('

') + html.count('

') + 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('

') + h2_count = content.html_content.count('

') + h3_count = content.html_content.count('

') + + 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('
    ') + content.html_content.count('
      ') + 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) + + diff --git a/backend/igny8_core/business/optimization/services/optimizer_service.py b/backend/igny8_core/business/optimization/services/optimizer_service.py new file mode 100644 index 00000000..930220dd --- /dev/null +++ b/backend/igny8_core/business/optimization/services/optimizer_service.py @@ -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) + + diff --git a/backend/igny8_core/business/site_building/__init__.py b/backend/igny8_core/business/site_building/__init__.py new file mode 100644 index 00000000..cb479ddf --- /dev/null +++ b/backend/igny8_core/business/site_building/__init__.py @@ -0,0 +1,6 @@ +""" +Site Building Business Logic +Phase 3: Site Builder +""" + + diff --git a/backend/igny8_core/business/site_building/models.py b/backend/igny8_core/business/site_building/models.py new file mode 100644 index 00000000..8e760d74 --- /dev/null +++ b/backend/igny8_core/business/site_building/models.py @@ -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})" + + diff --git a/backend/igny8_core/business/site_building/services/__init__.py b/backend/igny8_core/business/site_building/services/__init__.py new file mode 100644 index 00000000..4ad7ef33 --- /dev/null +++ b/backend/igny8_core/business/site_building/services/__init__.py @@ -0,0 +1,5 @@ +""" +Site Building Services +""" + + diff --git a/backend/igny8_core/business/site_building/services/file_management_service.py b/backend/igny8_core/business/site_building/services/file_management_service.py new file mode 100644 index 00000000..bc6d06d9 --- /dev/null +++ b/backend/igny8_core/business/site_building/services/file_management_service.py @@ -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 + + diff --git a/docs/planning/PHASE-3-4-IMPLEMENTATION-PLAN.md b/docs/planning/PHASE-3-4-IMPLEMENTATION-PLAN.md new file mode 100644 index 00000000..b46456b6 --- /dev/null +++ b/docs/planning/PHASE-3-4-IMPLEMENTATION-PLAN.md @@ -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** + +