Files
igny8/v2/V2-Execution-Docs/01A-sag-data-foundation.md
IGNY8 VPS (Salman) e78a41f11c v2-exece-docs
2026-03-23 10:30:51 +00:00

61 KiB
Raw Blame History

IGNY8 Phase 1: SAG Data Foundation (01A)

Core Data Models & CRUD APIs

Document Version: 1.1 Date: 2026-03-23 Phase: IGNY8 Phase 1 — SAG Data Foundation Status: Build Ready Source of Truth: Codebase at /data/app/igny8/ Audience: Claude Code, Backend Developers, Architects


1. CURRENT STATE

Existing IGNY8 Architecture

  • Framework: Django >=5.2.7 + Django REST Framework (from requirements.txt)
  • Database: PostgreSQL (version set by infra stack)
  • Primary Keys: BigAutoField (integer PKs — NOT UUIDs). DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
  • Multi-Tenancy: AccountContextMiddleware enforces tenant isolation
    • All new models inherit from AccountBaseModel or SiteSectorBaseModel
    • Automatic tenant filtering in querysets
  • Response Format: Unified across all endpoints
    {
      "success": true,
      "data": { /* model instance or list */ },
      "message": "Optional message"
    }
    
  • ViewSet Pattern: AccountModelViewSet (filters by account) and SiteSectorModelViewSet
  • Async Processing: Celery >=5.3.0 for background tasks
  • Existing Models (PLURAL class names, all use BigAutoField PKs):
    • Account, Site (in auth/models.py) — AccountBaseModel
    • Sector (in auth/models.py) — AccountBaseModel
    • Clusters (in business/planning/models.py) — SiteSectorBaseModel
    • Keywords (in business/planning/models.py) — SiteSectorBaseModel
    • ContentIdeas (in business/planning/models.py) — SiteSectorBaseModel
    • Tasks (in business/content/models.py) — SiteSectorBaseModel
    • Content (in business/content/models.py) — SiteSectorBaseModel
    • Images (in business/content/models.py) — SiteSectorBaseModel
    • SiteIntegration (in business/integration/models.py) — SiteSectorBaseModel
    • IntegrationProvider (in modules/system/models.py) — standalone

Frontend Stack

  • React ^19.0.0 + TypeScript ~5.7.2
  • Tailwind CSS ^4.0.8
  • Zustand ^5.0.8 (state management)
  • Vite ^6.1.0

What Doesn't Exist

  • Attribute-based cluster formation system
  • Blueprint versioning and health scoring
  • Sector attribute templates
  • CRUD APIs for managing clusters and attributes at scale
  • Cross-cluster linking framework (reserved for Phase 2 Linker)

2. WHAT TO BUILD

Overview

IGNY8 Phase 1 introduces the SAG (Sector Attribute Grid) Data Foundation — a system for:

  1. Attribute Discovery: Identify key dimensions (attributes) in a given sector
  2. Cluster Formation: Organize keywords and content around attribute intersections
  3. Blueprint Versioning: Create immutable snapshots of taxonomy and execution plans
  4. Health Scoring: Track completeness and coverage of the SAG blueprint

Scope

  • 4 new Django models in new app sag/
  • Modifications to 5 existing models (all backward-compatible, nullable fields)
  • Full CRUD APIs with nested routing
  • Admin interface for templates and blueprints
  • Service layer for complex operations (attribute discovery, cluster generation, health checks)
  • AI function stubs for Phase 23 integration (attribute extraction, cluster formation, etc.)

Non-Scope (Downstream Phases)

  • Internal linking strategy (Phase 2 Linker)
  • Content planning automation (Phase 2 Planner)
  • WordPress taxonomy sync (Phase 3 Syncer)
  • AI-powered attribute discovery (Phase 23)
  • Reporting & analytics UI (Phase 2)

3. DATA MODELS & API STRUCTURE

3.1 New Django App: sag/

Location: backend/igny8_core/sag/

Directory Structure:

sag/
├── __init__.py
├── models.py                    # SAGBlueprint, SAGAttribute, SAGCluster, SectorAttributeTemplate
├── serializers.py               # DRF serializers
├── views.py                     # ViewSets & custom actions
├── urls.py                      # URL routing (/api/v1/sag/*)
├── admin.py                     # Django admin registration
├── apps.py                      # App config
├── services/
│   ├── __init__.py
│   ├── blueprint_service.py     # Blueprint CRUD, versioning, confirmation
│   ├── attribute_service.py     # Attribute CRUD, discovery stubs
│   ├── cluster_service.py       # Cluster CRUD, formation, linking
│   ├── keyword_service.py       # Keyword extraction from cluster config
│   ├── template_service.py      # Template merging, sector lookups
│   └── health_service.py        # Health scoring, completeness checks
├── ai_functions/
│   ├── __init__.py
│   ├── attribute_discovery.py   # AI: Discover sector attributes (Phase 23)
│   ├── attribute_extraction.py  # AI: Extract values from content (Phase 23)
│   ├── attribute_population.py  # AI: Populate values into blueprint (Phase 23)
│   ├── cluster_formation.py     # AI: Form clusters from keywords (Phase 23)
│   ├── keyword_generation.py    # AI: Generate keywords per cluster (Phase 23)
│   └── content_planning.py      # AI: Plan supporting content (Phase 23)
├── migrations/
│   ├── 0001_initial.py          # Initial models
│   └── (auto-generated)
└── tests/
    ├── __init__.py
    ├── test_models.py
    ├── test_views.py
    └── test_services.py

3.2 Model Definitions

SAGBlueprint (New, AccountBaseModel)

Purpose: Immutable snapshot of taxonomy, clusters, attributes, and execution plan for a site.

Fields:

class SAGBlueprint(AccountBaseModel):
    """
    Core blueprint model: versioned taxonomy & cluster plan for a site.
    Inherits: id (BigAutoField, integer PK), account_id (FK), created_at, updated_at
    Note: Uses BigAutoField per project convention (DEFAULT_AUTO_FIELD), NOT UUID.
    """
    site = models.ForeignKey(
        'igny8_core_auth.Site',  # Actual app_label for Site model
        on_delete=models.CASCADE,
        related_name='sag_blueprints'
    )
    version = models.IntegerField(default=1)

    status = models.CharField(
        max_length=20,
        choices=[
            ('draft', 'Draft'),
            ('active', 'Active'),
            ('evolving', 'Evolving'),
            ('archived', 'Archived')
        ],
        default='draft'
    )

    source = models.CharField(
        max_length=20,
        choices=[
            ('site_builder', 'From Site Builder'),
            ('site_analysis', 'From Site Analysis'),
            ('manual', 'Manual Input')
        ],
        default='manual'
    )

    # Denormalized snapshots for fast reads
    attributes_json = models.JSONField(
        default=dict,
        help_text="Snapshot of all attributes: {attr_slug: {name, level, values, wp_slug}}"
    )
    clusters_json = models.JSONField(
        default=dict,
        help_text="Snapshot of all clusters: {cluster_slug: {name, type, keywords, hub_title}}"
    )

    taxonomy_plan = models.JSONField(
        default=dict,
        help_text="Execution taxonomy: organization of attributes by level, priority order"
    )

    execution_priority = models.JSONField(
        default=dict,
        help_text="Priority matrix: {cluster_slug: priority_score}"
    )

    # Phase 2 Linker reservation
    internal_linking_map = models.JSONField(
        default=dict,
        null=True,
        blank=True,
        help_text="Placeholder for Phase 2: cross-cluster internal linking strategy"
    )

    # Health & metrics
    sag_health_score = models.FloatField(
        default=0.0,
        validators=[MinValueValidator(0), MaxValueValidator(100)],
        help_text="0-100 score: completeness, coverage, consistency"
    )
    total_clusters = models.IntegerField(default=0)
    total_keywords = models.IntegerField(default=0)
    total_content_planned = models.IntegerField(default=0)
    total_content_published = models.IntegerField(default=0)

    # State tracking
    confirmed_at = models.DateTimeField(
        null=True,
        blank=True,
        help_text="When blueprint was moved from draft → active"
    )
    last_health_check = models.DateTimeField(
        null=True,
        blank=True,
        help_text="When last health_check() was run"
    )

    class Meta:
        unique_together = ('site', 'version', 'account')
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['site', 'status']),
            models.Index(fields=['account', 'status']),
        ]

    def __str__(self):
        return f"{self.site.name} SAG v{self.version} ({self.status})"

SAGAttribute (New, AccountBaseModel)

Purpose: Dimensional metadata for a blueprint (e.g., color, size, price_range, problem_type).

Fields:

class SAGAttribute(AccountBaseModel):
    """
    Attribute/dimension within a blueprint.
    Inherits: id (BigAutoField, integer PK), account_id (FK), created_at, updated_at
    """
    blueprint = models.ForeignKey(
        'sag.SAGBlueprint',
        on_delete=models.CASCADE,
        related_name='attributes'
    )

    name = models.CharField(
        max_length=100,
        help_text="Human-readable: 'Color', 'Product Type', 'Problem Category'"
    )
    slug = models.SlugField(
        max_length=100,
        help_text="URL-safe: 'color', 'product_type', 'problem_category'"
    )

    description = models.TextField(
        blank=True,
        help_text="What does this attribute represent in the context of this site?"
    )

    level = models.CharField(
        max_length=20,
        choices=[
            ('primary', 'Primary (main taxonomy dimension)'),
            ('secondary', 'Secondary (supporting dimension)'),
            ('tertiary', 'Tertiary (refinement dimension)')
        ],
        default='secondary'
    )

    # Array of {name, slug} objects
    values = models.JSONField(
        default=list,
        help_text="Possible values: [{name: 'Red', slug: 'red'}, {name: 'Blue', slug: 'blue'}, ...]"
    )

    # WordPress integration (Phase 3)
    wp_taxonomy_slug = models.CharField(
        max_length=100,
        null=True,
        blank=True,
        help_text="WordPress taxonomy slug for syncing (e.g., 'pa_color' for WooCommerce)"
    )

    wp_sync_status = models.CharField(
        max_length=20,
        choices=[
            ('pending', 'Pending Sync'),
            ('synced', 'Synced to WordPress'),
            ('failed', 'Sync Failed')
        ],
        default='pending'
    )

    sort_order = models.IntegerField(
        default=0,
        help_text="Display order within blueprint"
    )

    class Meta:
        unique_together = ('blueprint', 'slug', 'account')
        ordering = ['sort_order', 'name']
        indexes = [
            models.Index(fields=['blueprint', 'level']),
        ]

    def __str__(self):
        return f"{self.name} ({self.level}) in {self.blueprint.site.name}"

SAGCluster (New, AccountBaseModel)

Purpose: A content hub organized around attribute intersections; contains keywords, structure, and supporting content plan.

Fields:

class SAGCluster(AccountBaseModel):
    """
    Cluster: hub page + supporting content organized around attribute intersection.
    Inherits: id (BigAutoField, integer PK), account_id (FK), created_at, updated_at
    
    IMPORTANT: This model coexists with the existing `Clusters` model (in business/planning/models.py).
    Existing Clusters are pure topic-keyword groups. SAGClusters add attribute-based dimensionality.
    They are linked via an optional FK on the existing Clusters model.
    """
    blueprint = models.ForeignKey(
        'sag.SAGBlueprint',
        on_delete=models.CASCADE,
        related_name='clusters'
    )
    site = models.ForeignKey(
        'igny8_core_auth.Site',  # Actual app_label for Site model
        on_delete=models.CASCADE,
        related_name='sag_clusters'
    )

    # Identity
    name = models.CharField(
        max_length=200,
        help_text="Cluster name (may become hub page title)"
    )
    slug = models.SlugField(
        max_length=200,
        help_text="URL-safe identifier: used in hub_page_url_slug"
    )

    cluster_type = models.CharField(
        max_length=50,
        choices=[
            ('product_category', 'Product Category'),
            ('condition_problem', 'Condition / Problem'),
            ('feature', 'Feature / Benefit'),
            ('brand', 'Brand'),
            ('informational', 'Informational / How-to'),
            ('comparison', 'Comparison')
        ],
        help_text="Semantic type for content strategy"
    )

    # Attribute intersection: {dimension_name: value_slug}
    attribute_intersection = models.JSONField(
        default=dict,
        help_text="Attribute values defining this cluster: {'color': 'red', 'material': 'leather'}"
    )

    # Hub page design
    hub_page_title = models.CharField(
        max_length=300,
        help_text="SEO-optimized hub page title"
    )
    hub_page_type = models.CharField(
        max_length=100,
        default='cluster_hub',
        help_text="'cluster_hub' or custom type"
    )
    hub_page_structure = models.CharField(
        max_length=100,
        default='guide_tutorial',
        choices=[
            ('guide_tutorial', 'Guide / Tutorial'),
            ('product_comparison', 'Product Comparison'),
            ('category_overview', 'Category Overview'),
            ('problem_solution', 'Problem / Solution'),
            ('resource_library', 'Resource Library')
        ],
        help_text="Content structure template for hub page"
    )
    hub_page_url_slug = models.CharField(
        max_length=300,
        help_text="URL slug for the hub page (e.g., '/guides/red-leather-bags/')"
    )

    # Keyword strategy
    auto_generated_keywords = models.JSONField(
        default=list,
        help_text="Array of keyword strings generated for this cluster"
    )

    # Supporting content plan: [{ title, type, structure, keywords }, ...]
    supporting_content_plan = models.JSONField(
        default=list,
        help_text="Planned supporting articles/guides linked to this hub"
    )

    # Linking
    linked_attribute_terms = models.JSONField(
        default=list,
        help_text="Slugs of attribute values mentioned in cluster (for internal linking)"
    )
    cross_cluster_links = models.JSONField(
        default=list,
        help_text="Slugs of related clusters for cross-linking"
    )

    # Status & metrics
    status = models.CharField(
        max_length=20,
        choices=[
            ('planned', 'Planned'),
            ('partial', 'Partially Complete'),
            ('complete', 'Complete')
        ],
        default='planned'
    )

    content_count = models.IntegerField(
        default=0,
        help_text="Number of content pieces published for this cluster"
    )

    link_coverage_score = models.FloatField(
        default=0.0,
        validators=[MinValueValidator(0), MaxValueValidator(100)],
        help_text="0-100 score: internal linking completeness"
    )

    backlink_target_tier = models.CharField(
        max_length=5,
        choices=[('T1', 'T1'), ('T2', 'T2'), ('T3', 'T3'), ('T4', 'T4'), ('T5', 'T5')],
        null=True,
        blank=True,
        help_text="Target tier for external backlinks (T1 = highest authority)"
    )

    class Meta:
        unique_together = ('blueprint', 'slug', 'account')
        ordering = ['name']
        indexes = [
            models.Index(fields=['blueprint', 'cluster_type']),
            models.Index(fields=['site', 'status']),
            models.Index(fields=['account', 'status']),
        ]

    def __str__(self):
        return f"{self.name} ({self.cluster_type}) in {self.site.name}"

SectorAttributeTemplate (New, Base Model)

Purpose: Admin-managed templates for attribute frameworks and keyword patterns by industry/sector.

Fields:

class SectorAttributeTemplate(models.Model):
    """
    Reusable template for attributes and keywords by industry + sector.
    NOT tied to Account (admin-only, shared across tenants).
    Uses BigAutoField PK per project convention (do NOT use UUID).
    """

    industry = models.CharField(
        max_length=200,
        help_text="E.g., 'Fashion', 'SaaS', 'Healthcare'"
    )
    sector = models.CharField(
        max_length=200,
        help_text="E.g., 'Womens Apparel', 'CRM Software', 'Dental Services'"
    )

    site_type = models.CharField(
        max_length=50,
        choices=[
            ('e_commerce', 'E-Commerce'),
            ('services', 'Services'),
            ('saas', 'SaaS'),
            ('content', 'Content / Blog'),
            ('local_business', 'Local Business'),
            ('brand', 'Brand / Corporate')
        ],
        default='e_commerce'
    )

    # Template structure: {attribute_name: {level, suggested_values: [...]}, ...}
    attribute_framework = models.JSONField(
        default=dict,
        help_text="Template attributes: {'color': {'level': 'primary', 'suggested_values': ['red', 'blue', ...]}, ...}"
    )

    # Keyword templates per cluster type: {cluster_type: {pattern, examples}, ...}
    keyword_templates = models.JSONField(
        default=dict,
        help_text="Keyword generation patterns by cluster type"
    )

    source = models.CharField(
        max_length=50,
        choices=[
            ('manual', 'Manual'),
            ('ai_generated', 'AI-Generated'),
            ('user_contributed', 'User Contributed')
        ],
        default='manual'
    )

    is_active = models.BooleanField(
        default=True,
        help_text="Can be used for new blueprints"
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        unique_together = ('industry', 'sector')
        ordering = ['industry', 'sector']
        indexes = [
            models.Index(fields=['industry', 'sector', 'is_active']),
        ]

    def __str__(self):
        return f"{self.industry}{self.sector} ({self.site_type})"

3.3 Modifications to Existing Models

All modifications are backward-compatible and nullable. Existing records are unaffected.

Site (in auth/models.py, app_label: igny8_core_auth)

class Site(SoftDeletableModel, AccountBaseModel):
    # ... existing fields ...

    # NEW: SAG integration (nullable, backward-compatible)
    sag_blueprint = models.ForeignKey(
        'sag.SAGBlueprint',
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='sites_using_this_blueprint',
        help_text="Active SAG blueprint (if any) for this site"
    )

Clusters (in business/planning/models.py, app_label: planner)

class Clusters(SoftDeletableModel, SiteSectorBaseModel):
    # ... existing fields: name, description, keywords_count, volume,
    #     mapped_pages, status(new/mapped), disabled ...

    # NEW: SAG integration (nullable, backward-compatible)
    sag_cluster = models.ForeignKey(
        'sag.SAGCluster',
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='traditional_clusters',
        help_text="Link to SAG cluster if part of SAG blueprint"
    )

    cluster_type = models.CharField(
        max_length=50,
        null=True,
        blank=True,
        help_text="Type from SAG (e.g., 'product_category'). Nullable for legacy clusters."
    )

Tasks (in business/content/models.py, app_label: writer)

class Tasks(SoftDeletableModel, SiteSectorBaseModel):
    # ... existing fields: title, description, content_type, content_structure,
    #     keywords, word_count, status(queued/completed) ...
    # NOTE: Already has `cluster = FK('planner.Clusters')` and
    #       `idea = FK('planner.ContentIdeas')` — these are NOT being replaced.
    #       The new sag_cluster FK is an ADDITIONAL link to the SAG layer.

    # NEW: SAG integration (nullable, backward-compatible)
    sag_cluster = models.ForeignKey(
        'sag.SAGCluster',
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='tasks',
        help_text="SAG cluster this task contributes to (if any)"
    )

    blueprint_context = models.JSONField(
        default=dict,
        null=True,
        blank=True,
        help_text="Context from SAG blueprint: keywords, attribute_intersection, hub_title"
    )

Content (in business/content/models.py, app_label: writer)

class Content(SoftDeletableModel, SiteSectorBaseModel):
    # ... existing fields ...
    # NOTE: Already has task FK (to writer.Tasks which has cluster FK).
    #       The new sag_cluster FK is an ADDITIONAL direct link to SAG layer.

    # NEW: SAG integration (nullable, backward-compatible)
    sag_cluster = models.ForeignKey(
        'sag.SAGCluster',
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='content_pieces',
        help_text="SAG cluster this content was published for (if any)"
    )

ContentIdeas (in business/planning/models.py, app_label: planner)

class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel):
    # ... existing fields ...

    # NEW: SAG integration (nullable, backward-compatible)
    sag_cluster = models.ForeignKey(
        'sag.SAGCluster',
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name='content_ideas',
        help_text="SAG cluster this idea belongs to (if any)"
    )

    idea_source = models.CharField(
        max_length=50,
        null=True,
        blank=True,
        choices=[
            ('sag_blueprint', 'From SAG Blueprint'),
            ('manual', 'Manual Entry'),
            ('user_suggestion', 'User Suggestion')
        ],
        help_text="Where the idea originated"
    )

3.4 Serializers

File: sag/serializers.py

from rest_framework import serializers
from .models import SAGBlueprint, SAGAttribute, SAGCluster, SectorAttributeTemplate


class SAGAttributeSerializer(serializers.ModelSerializer):
    class Meta:
        model = SAGAttribute
        fields = [
            'id', 'blueprint', 'name', 'slug', 'description',
            'level', 'values', 'wp_taxonomy_slug', 'wp_sync_status',
            'sort_order', 'created_at', 'updated_at'
        ]
        read_only_fields = ['id', 'created_at', 'updated_at']


class SAGClusterSerializer(serializers.ModelSerializer):
    class Meta:
        model = SAGCluster
        fields = [
            'id', 'blueprint', 'site', 'name', 'slug', 'cluster_type',
            'attribute_intersection', 'hub_page_title', 'hub_page_type',
            'hub_page_structure', 'hub_page_url_slug',
            'auto_generated_keywords', 'supporting_content_plan',
            'linked_attribute_terms', 'cross_cluster_links',
            'status', 'content_count', 'link_coverage_score',
            'backlink_target_tier', 'created_at', 'updated_at'
        ]
        read_only_fields = ['id', 'created_at', 'updated_at']


class SAGBlueprintDetailSerializer(serializers.ModelSerializer):
    """Full blueprint with nested attributes and clusters."""
    attributes = SAGAttributeSerializer(many=True, read_only=True)
    clusters = SAGClusterSerializer(many=True, read_only=True)

    class Meta:
        model = SAGBlueprint
        fields = [
            'id', 'site', 'account', 'version', 'status', 'source',
            'attributes_json', 'clusters_json',
            'taxonomy_plan', 'execution_priority', 'internal_linking_map',
            'sag_health_score', 'total_clusters', 'total_keywords',
            'total_content_planned', 'total_content_published',
            'confirmed_at', 'last_health_check',
            'attributes', 'clusters',
            'created_at', 'updated_at'
        ]
        read_only_fields = [
            'id', 'account', 'created_at', 'updated_at',
            'sag_health_score', 'total_clusters', 'total_keywords'
        ]


class SAGBlueprintListSerializer(serializers.ModelSerializer):
    """Lightweight blueprint listing."""
    class Meta:
        model = SAGBlueprint
        fields = [
            'id', 'site', 'version', 'status', 'source',
            'sag_health_score', 'total_clusters', 'total_keywords',
            'confirmed_at', 'created_at'
        ]
        read_only_fields = [
            'id', 'created_at',
            'sag_health_score', 'total_clusters', 'total_keywords'
        ]


class SectorAttributeTemplateSerializer(serializers.ModelSerializer):
    class Meta:
        model = SectorAttributeTemplate
        fields = [
            'id', 'industry', 'sector', 'site_type',
            'attribute_framework', 'keyword_templates',
            'source', 'is_active', 'created_at', 'updated_at'
        ]
        read_only_fields = ['id', 'created_at', 'updated_at']

3.5 ViewSets & URL Routing

File: sag/views.py

from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter

from igny8_core.common.permissions import IsAccountMember
from igny8_core.common.views import AccountModelViewSet, SiteSectorModelViewSet
from .models import SAGBlueprint, SAGAttribute, SAGCluster, SectorAttributeTemplate
from .serializers import (
    SAGBlueprintDetailSerializer, SAGBlueprintListSerializer,
    SAGAttributeSerializer, SAGClusterSerializer,
    SectorAttributeTemplateSerializer
)
from .services import (
    blueprint_service, attribute_service, cluster_service,
    health_service, template_service
)


class SAGBlueprintViewSet(AccountModelViewSet):
    """
    CRUD for SAG Blueprints.
    Nested under /api/v1/sag/blueprints/
    """
    permission_classes = [IsAuthenticated, IsAccountMember]
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_fields = ['site', 'status', 'source']
    search_fields = ['site__name']
    ordering_fields = ['-created_at', 'version', 'status']

    def get_serializer_class(self):
        if self.action == 'list':
            return SAGBlueprintListSerializer
        return SAGBlueprintDetailSerializer

    def get_queryset(self):
        """Filter by account tenant."""
        return SAGBlueprint.objects.filter(
            account=self.request.user.account
        ).select_related('site').prefetch_related('attributes', 'clusters')

    @action(detail=True, methods=['post'])
    def confirm(self, request, pk=None):
        """Move blueprint from draft → active."""
        blueprint = self.get_object()
        try:
            blueprint = blueprint_service.confirm_blueprint(blueprint)
            serializer = self.get_serializer(blueprint)
            return Response({
                'success': True,
                'data': serializer.data,
                'message': f'Blueprint v{blueprint.version} activated.'
            })
        except Exception as e:
            return Response({
                'success': False,
                'data': None,
                'message': str(e)
            }, status=status.HTTP_400_BAD_REQUEST)

    @action(detail=True, methods=['post'])
    def archive(self, request, pk=None):
        """Move blueprint from active → archived."""
        blueprint = self.get_object()
        try:
            blueprint = blueprint_service.archive_blueprint(blueprint)
            serializer = self.get_serializer(blueprint)
            return Response({
                'success': True,
                'data': serializer.data,
                'message': f'Blueprint v{blueprint.version} archived.'
            })
        except Exception as e:
            return Response({
                'success': False,
                'data': None,
                'message': str(e)
            }, status=status.HTTP_400_BAD_REQUEST)

    @action(detail=True, methods=['post'])
    def regenerate(self, request, pk=None):
        """Create new version of blueprint from current."""
        blueprint = self.get_object()
        try:
            new_blueprint = blueprint_service.create_new_version(blueprint)
            serializer = self.get_serializer(new_blueprint)
            return Response({
                'success': True,
                'data': serializer.data,
                'message': f'New blueprint v{new_blueprint.version} created.'
            })
        except Exception as e:
            return Response({
                'success': False,
                'data': None,
                'message': str(e)
            }, status=status.HTTP_400_BAD_REQUEST)

    @action(detail=True, methods=['post'])
    def health_check(self, request, pk=None):
        """Compute health score for blueprint."""
        blueprint = self.get_object()
        try:
            result = health_service.compute_blueprint_health(blueprint)
            return Response({
                'success': True,
                'data': result,
                'message': 'Health check complete.'
            })
        except Exception as e:
            return Response({
                'success': False,
                'data': None,
                'message': str(e)
            }, status=status.HTTP_400_BAD_REQUEST)

    @action(detail=False, methods=['get'])
    def active_by_site(self, request):
        """GET /api/v1/sag/blueprints/active_by_site/?site_id=<int>"""
        site_id = request.query_params.get('site_id')
        if not site_id:
            return Response({
                'success': False,
                'data': None,
                'message': 'site_id query param required'
            }, status=status.HTTP_400_BAD_REQUEST)

        try:
            blueprint = blueprint_service.get_active_blueprint(
                site_id, request.user.account
            )
            serializer = self.get_serializer(blueprint)
            return Response({
                'success': True,
                'data': serializer.data,
                'message': None
            })
        except SAGBlueprint.DoesNotExist:
            return Response({
                'success': False,
                'data': None,
                'message': 'No active blueprint for this site.'
            }, status=status.HTTP_404_NOT_FOUND)


class SAGAttributeViewSet(AccountModelViewSet):
    """
    CRUD for SAG Attributes within a blueprint.
    Nested under /api/v1/sag/blueprints/{blueprint_id}/attributes/
    """
    permission_classes = [IsAuthenticated, IsAccountMember]
    serializer_class = SAGAttributeSerializer
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_fields = ['level']
    ordering_fields = ['sort_order', 'name']

    def get_queryset(self):
        blueprint_id = self.kwargs.get('blueprint_id')
        return SAGAttribute.objects.filter(
            blueprint_id=blueprint_id,
            account=self.request.user.account
        )

    def perform_create(self, serializer):
        blueprint_id = self.kwargs.get('blueprint_id')
        serializer.save(
            blueprint_id=blueprint_id,
            account=self.request.user.account
        )


class SAGClusterViewSet(AccountModelViewSet):
    """
    CRUD for SAG Clusters within a blueprint.
    Nested under /api/v1/sag/blueprints/{blueprint_id}/clusters/
    """
    permission_classes = [IsAuthenticated, IsAccountMember]
    serializer_class = SAGClusterSerializer
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_fields = ['cluster_type', 'status']
    search_fields = ['name', 'hub_page_title']
    ordering_fields = ['name', 'status', 'content_count']

    def get_queryset(self):
        blueprint_id = self.kwargs.get('blueprint_id')
        return SAGCluster.objects.filter(
            blueprint_id=blueprint_id,
            account=self.request.user.account
        )

    def perform_create(self, serializer):
        blueprint_id = self.kwargs.get('blueprint_id')
        blueprint = SAGBlueprint.objects.get(id=blueprint_id)
        serializer.save(
            blueprint=blueprint,
            site=blueprint.site,
            account=self.request.user.account
        )


class SectorAttributeTemplateViewSet(viewsets.ModelViewSet):
    """
    Admin-only CRUD for sector templates (global, not account-specific).
    Endpoint: /api/v1/sag/sector-templates/
    """
    permission_classes = [IsAuthenticated, IsAdminUser]
    serializer_class = SectorAttributeTemplateSerializer
    queryset = SectorAttributeTemplate.objects.filter(is_active=True)
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_fields = ['industry', 'site_type']
    search_fields = ['industry', 'sector']
    ordering_fields = ['industry', 'sector']

    @action(detail=False, methods=['get'])
    def by_industry_sector(self, request):
        """
        GET /api/v1/sag/sector-templates/by_industry_sector/?industry=<str>&sector=<str>
        """
        industry = request.query_params.get('industry')
        sector = request.query_params.get('sector')

        if not industry or not sector:
            return Response({
                'success': False,
                'data': None,
                'message': 'industry and sector query params required'
            }, status=status.HTTP_400_BAD_REQUEST)

        try:
            template = SectorAttributeTemplate.objects.get(
                industry=industry, sector=sector, is_active=True
            )
            serializer = self.get_serializer(template)
            return Response({
                'success': True,
                'data': serializer.data,
                'message': None
            })
        except SectorAttributeTemplate.DoesNotExist:
            return Response({
                'success': False,
                'data': None,
                'message': f'No template found for {industry}{sector}'
            }, status=status.HTTP_404_NOT_FOUND)

    @action(detail=True, methods=['post'])
    def merge_multi_sector(self, request, pk=None):
        """
        POST /api/v1/sag/sector-templates/{id}/merge_multi_sector/
        Merge multiple sector templates into a single blueprint template.
        """
        template = self.get_object()
        sectors = request.data.get('sectors', [])

        try:
            merged = template_service.merge_templates(template, sectors)
            return Response({
                'success': True,
                'data': merged,
                'message': 'Templates merged successfully.'
            })
        except Exception as e:
            return Response({
                'success': False,
                'data': None,
                'message': str(e)
            }, status=status.HTTP_400_BAD_REQUEST)

File: sag/urls.py

from django.urls import path, include
from rest_framework.routers import SimpleRouter
from .views import (
    SAGBlueprintViewSet, SAGAttributeViewSet, SAGClusterViewSet,
    SectorAttributeTemplateViewSet
)

router = SimpleRouter()
router.register(r'blueprints', SAGBlueprintViewSet, basename='sag-blueprint')
router.register(r'sector-templates', SectorAttributeTemplateViewSet, basename='sector-template')

# Nested routers for attributes & clusters
blueprints_router = SimpleRouter()
blueprints_router.register(
    r'attributes',
    SAGAttributeViewSet,
    basename='sag-attribute'
)
blueprints_router.register(
    r'clusters',
    SAGClusterViewSet,
    basename='sag-cluster'
)

urlpatterns = [
    path('', include(router.urls)),
    path('blueprints/<int:blueprint_id>/', include(blueprints_router.urls)),
]

Root URLs Integration (in backend/igny8_core/urls.py or api/v1/urls.py):

urlpatterns = [
    path('api/v1/sag/', include('sag.urls')),
]

3.6 Admin Configuration

File: sag/admin.py

from django.contrib import admin
from .models import SAGBlueprint, SAGAttribute, SAGCluster, SectorAttributeTemplate


@admin.register(SAGBlueprint)
class SAGBlueprintAdmin(admin.ModelAdmin):
    list_display = [
        'site', 'version', 'status', 'sag_health_score',
        'total_clusters', 'total_keywords', 'created_at'
    ]
    list_filter = ['status', 'source', 'account__name', 'created_at']
    search_fields = ['site__name', 'account__name']
    readonly_fields = [
        'id', 'created_at', 'updated_at',
        'sag_health_score', 'total_clusters', 'total_keywords',
        'confirmed_at', 'last_health_check'
    ]
    fieldsets = (
        ('Identity', {
            'fields': ('id', 'site', 'account', 'version', 'status', 'source')
        }),
        ('Content', {
            'fields': (
                'attributes_json', 'clusters_json',
                'taxonomy_plan', 'execution_priority'
            )
        }),
        ('Health & Metrics', {
            'fields': (
                'sag_health_score', 'total_clusters', 'total_keywords',
                'total_content_planned', 'total_content_published',
                'last_health_check'
            )
        }),
        ('State', {
            'fields': ('confirmed_at', 'created_at', 'updated_at')
        }),
        ('Future: Phase 2 Linker', {
            'fields': ('internal_linking_map',),
            'classes': ('collapse',)
        }),
    )


@admin.register(SAGAttribute)
class SAGAttributeAdmin(admin.ModelAdmin):
    list_display = ['name', 'blueprint', 'level', 'slug', 'sort_order']
    list_filter = ['level', 'wp_sync_status', 'account__name']
    search_fields = ['name', 'slug', 'blueprint__site__name']
    readonly_fields = ['id', 'created_at', 'updated_at']
    fieldsets = (
        ('Identity', {
            'fields': ('id', 'blueprint', 'account', 'name', 'slug')
        }),
        ('Definition', {
            'fields': ('description', 'level', 'values', 'sort_order')
        }),
        ('WordPress Integration', {
            'fields': ('wp_taxonomy_slug', 'wp_sync_status'),
            'classes': ('collapse',)
        }),
        ('Timestamps', {
            'fields': ('created_at', 'updated_at'),
            'classes': ('collapse',)
        }),
    )


@admin.register(SAGCluster)
class SAGClusterAdmin(admin.ModelAdmin):
    list_display = [
        'name', 'cluster_type', 'blueprint', 'status',
        'content_count', 'link_coverage_score'
    ]
    list_filter = ['cluster_type', 'status', 'account__name', 'created_at']
    search_fields = ['name', 'slug', 'hub_page_title', 'blueprint__site__name']
    readonly_fields = ['id', 'created_at', 'updated_at']
    fieldsets = (
        ('Identity', {
            'fields': ('id', 'blueprint', 'site', 'account', 'name', 'slug')
        }),
        ('Type & Content', {
            'fields': (
                'cluster_type', 'attribute_intersection',
                'auto_generated_keywords', 'supporting_content_plan'
            )
        }),
        ('Hub Page', {
            'fields': (
                'hub_page_title', 'hub_page_type',
                'hub_page_structure', 'hub_page_url_slug'
            )
        }),
        ('Linking', {
            'fields': (
                'linked_attribute_terms', 'cross_cluster_links',
                'link_coverage_score', 'backlink_target_tier'
            )
        }),
        ('Status & Metrics', {
            'fields': ('status', 'content_count')
        }),
        ('Timestamps', {
            'fields': ('created_at', 'updated_at'),
            'classes': ('collapse',)
        }),
    )


@admin.register(SectorAttributeTemplate)
class SectorAttributeTemplateAdmin(admin.ModelAdmin):
    list_display = ['industry', 'sector', 'site_type', 'source', 'is_active', 'updated_at']
    list_filter = ['site_type', 'source', 'is_active', 'industry']
    search_fields = ['industry', 'sector']
    readonly_fields = ['id', 'created_at', 'updated_at']
    fieldsets = (
        ('Identity', {
            'fields': ('id', 'industry', 'sector', 'site_type')
        }),
        ('Templates', {
            'fields': ('attribute_framework', 'keyword_templates')
        }),
        ('Metadata', {
            'fields': ('source', 'is_active', 'created_at', 'updated_at')
        }),
    )

3.7 Service Layer

File: sag/services/blueprint_service.py

from django.utils import timezone
from ..models import SAGBlueprint
from .cluster_service import synchronize_denormalized_clusters
from .attribute_service import synchronize_denormalized_attributes


def confirm_blueprint(blueprint):
    """Move blueprint from draft → active. Update Site reference."""
    if blueprint.status != 'draft':
        raise ValueError(f"Cannot confirm blueprint with status {blueprint.status}")

    blueprint.status = 'active'
    blueprint.confirmed_at = timezone.now()
    blueprint.save()

    # Update Site to reference this blueprint
    blueprint.site.sag_blueprint = blueprint
    blueprint.site.save()

    return blueprint


def archive_blueprint(blueprint):
    """Move blueprint from active → archived."""
    if blueprint.status not in ['active', 'evolving']:
        raise ValueError(f"Cannot archive blueprint with status {blueprint.status}")

    blueprint.status = 'archived'
    blueprint.save()

    # Unlink from Site
    blueprint.site.sag_blueprint = None
    blueprint.site.save()

    return blueprint


def create_new_version(blueprint):
    """Create a new version by copying current blueprint."""
    new_version = SAGBlueprint.objects.create(
        site=blueprint.site,
        account=blueprint.account,
        version=blueprint.version + 1,
        status='draft',
        source=blueprint.source,
        attributes_json=blueprint.attributes_json.copy(),
        clusters_json=blueprint.clusters_json.copy(),
        taxonomy_plan=blueprint.taxonomy_plan.copy(),
        execution_priority=blueprint.execution_priority.copy(),
        internal_linking_map=blueprint.internal_linking_map.copy() if blueprint.internal_linking_map else {},
    )

    # Duplicate attributes & clusters (create new PK)
    for attr in blueprint.attributes.all():
        attr.pk = None
        attr.blueprint = new_version
        attr.save()

    for cluster in blueprint.clusters.all():
        cluster.pk = None
        cluster.blueprint = new_version
        cluster.save()

    return new_version


def get_active_blueprint(site_id, account):
    """Retrieve the active blueprint for a site, or None."""
    return SAGBlueprint.objects.get(
        site_id=site_id,
        account=account,
        status='active'
    )

File: sag/services/health_service.py

from django.utils import timezone
from ..models import SAGBlueprint, SAGCluster
import logging

logger = logging.getLogger(__name__)


def compute_blueprint_health(blueprint):
    """
    Compute health score (0-100) based on:
    - Attribute completeness: are all attributes defined?
    - Cluster coverage: do clusters cover attribute space?
    - Content completion: how much content is published?
    - Structural consistency: are all required fields filled?
    """
    score = 0.0

    # 1. Attribute completeness (25 points)
    attribute_count = blueprint.attributes.count()
    if attribute_count > 0:
        score += 25

    # 2. Cluster coverage (25 points)
    cluster_count = blueprint.clusters.count()
    if cluster_count >= 5:
        score += 25
    elif cluster_count > 0:
        score += (cluster_count / 5) * 25

    # 3. Content completion (30 points)
    total_expected = max(blueprint.total_keywords or 1, 10)
    if blueprint.total_content_published > 0:
        completion_ratio = blueprint.total_content_published / total_expected
        score += min(30, completion_ratio * 30)

    # 4. Structural consistency (20 points)
    consistency_score = 0
    if blueprint.taxonomy_plan:
        consistency_score += 10
    if blueprint.execution_priority:
        consistency_score += 10
    score += consistency_score

    # Update blueprint
    blueprint.sag_health_score = min(100.0, score)
    blueprint.last_health_check = timezone.now()
    blueprint.save()

    logger.info(f"Computed health for blueprint {blueprint.id}: {blueprint.sag_health_score}")

    return {
        'blueprint_id': blueprint.id,
        'health_score': blueprint.sag_health_score,
        'attribute_count': attribute_count,
        'cluster_count': cluster_count,
        'content_published': blueprint.total_content_published,
        'last_check': blueprint.last_health_check.isoformat()
    }


def synchronize_denormalized_fields(blueprint):
    """Update snapshot JSONs in blueprint from current attributes & clusters."""
    # Rebuild attributes_json
    blueprint.attributes_json = {
        attr.slug: {
            'name': attr.name,
            'level': attr.level,
            'values': attr.values,
            'wp_taxonomy_slug': attr.wp_taxonomy_slug
        }
        for attr in blueprint.attributes.all()
    }

    # Rebuild clusters_json
    blueprint.clusters_json = {
        cluster.slug: {
            'name': cluster.name,
            'type': cluster.cluster_type,
            'hub_title': cluster.hub_page_title,
            'keywords': cluster.auto_generated_keywords,
            'status': cluster.status
        }
        for cluster in blueprint.clusters.all()
    }

    # Update totals
    blueprint.total_clusters = blueprint.clusters.count()
    blueprint.total_keywords = sum(
        len(c.auto_generated_keywords) for c in blueprint.clusters.all()
    )
    blueprint.total_content_published = blueprint.clusters.aggregate(
        total=models.Sum('content_count')
    )['total'] or 0

    blueprint.save()
    return blueprint

File: sag/services/cluster_service.py

from ..models import SAGCluster


def create_cluster_from_attribute_intersection(blueprint, site, cluster_type,
                                               attribute_intersection, name, slug):
    """Create a cluster with given attribute intersection."""
    cluster = SAGCluster.objects.create(
        blueprint=blueprint,
        site=site,
        account=blueprint.account,
        name=name,
        slug=slug,
        cluster_type=cluster_type,
        attribute_intersection=attribute_intersection,
        hub_page_title=f"{name} Guide",
        hub_page_url_slug=slug,
        status='planned'
    )
    return cluster


def update_cluster_content_metrics(cluster):
    """Update content_count based on related Content objects."""
    content_count = cluster.content_pieces.filter(
        status='published'
    ).count()
    cluster.content_count = content_count
    cluster.save()
    return cluster

File: sag/services/attribute_service.py

from ..models import SAGAttribute


def create_attribute(blueprint, name, slug, level, values, description=''):
    """Create an attribute within a blueprint."""
    # Ensure values is array of {name, slug}
    if isinstance(values, list) and values and isinstance(values[0], str):
        # Convert string list to {slug: name, name: name}
        values = [{'name': v, 'slug': v.lower().replace(' ', '_')} for v in values]

    attr = SAGAttribute.objects.create(
        blueprint=blueprint,
        account=blueprint.account,
        name=name,
        slug=slug,
        level=level,
        values=values,
        description=description
    )
    return attr

File: sag/services/template_service.py

from ..models import SectorAttributeTemplate


def merge_templates(base_template, sector_slugs):
    """Merge multiple sector templates into a unified framework."""
    merged_attributes = base_template.attribute_framework.copy()
    merged_keywords = base_template.keyword_templates.copy()

    # For each sector_slug, fetch and merge
    for sector_slug in sector_slugs:
        try:
            template = SectorAttributeTemplate.objects.get(
                industry=base_template.industry,
                sector=sector_slug,
                is_active=True
            )
            # Deep merge
            merged_attributes.update(template.attribute_framework)
            merged_keywords.update(template.keyword_templates)
        except SectorAttributeTemplate.DoesNotExist:
            pass

    return {
        'attribute_framework': merged_attributes,
        'keyword_templates': merged_keywords
    }

File: sag/services/__init__.py

from . import blueprint_service
from . import attribute_service
from . import cluster_service
from . import template_service
from . import health_service
from . import keyword_service

__all__ = [
    'blueprint_service',
    'attribute_service',
    'cluster_service',
    'template_service',
    'health_service',
    'keyword_service',
]

3.8 AI Function Stubs

File: sag/ai_functions/attribute_discovery.py

"""
Phase 23: AI-powered attribute discovery from site content.
Stub for now; will integrate with Claude/Anthropic API in Phase 2.
"""


def discover_attributes_from_site_content(site, content_samples):
    """
    [STUB] Analyze site content to discover domain-specific attributes.

    Args:
        site: Site instance
        content_samples: List of text samples from site

    Returns:
        List of discovered attributes: [{name, level, suggested_values}, ...]
    """
    raise NotImplementedError("Phase 2: AI discovery not yet implemented")

File: sag/ai_functions/cluster_formation.py

"""
Phase 23: AI-powered cluster formation from keywords and attributes.
"""


def form_clusters_from_keywords(keywords, attributes, cluster_type_hints=None):
    """
    [STUB] Group keywords into clusters based on attribute intersection.

    Args:
        keywords: List of keyword strings
        attributes: List of SAGAttribute instances
        cluster_type_hints: Optional dict of keyword → cluster_type mappings

    Returns:
        List of clusters: [{name, slug, attribute_intersection, keywords}, ...]
    """
    raise NotImplementedError("Phase 2: AI cluster formation not yet implemented")

File: sag/ai_functions/keyword_generation.py

"""
Phase 23: AI-powered keyword generation for clusters.
"""


def generate_keywords_for_cluster(cluster, attribute_intersection, competitor_keywords=None):
    """
    [STUB] Generate SEO keywords for a cluster given attributes.

    Args:
        cluster: SAGCluster instance
        attribute_intersection: Dict of {attribute_slug: value_slug}
        competitor_keywords: Optional list of competitor keywords to avoid/differentiate

    Returns:
        List of generated keywords: [{keyword, search_volume, difficulty, intent}, ...]
    """
    raise NotImplementedError("Phase 2: AI keyword generation not yet implemented")

File: sag/ai_functions/content_planning.py

"""
Phase 2: AI-powered content planning for clusters.
"""


def plan_supporting_content(cluster, hub_page_title, num_articles=5):
    """
    [STUB] Generate supporting article plan for a cluster hub.

    Args:
        cluster: SAGCluster instance
        hub_page_title: Title of the hub page
        num_articles: Number of supporting articles to plan

    Returns:
        List of article plans: [{title, type, structure, keywords}, ...]
    """
    raise NotImplementedError("Phase 2: AI content planning not yet implemented")

File: sag/ai_functions/__init__.py

# Stubs for Phase 23 AI integration

4. IMPLEMENTATION STEPS

Phase 1A: Data Layer (This Sprint)

  1. Create Django App

    • Generate sag/ app in backend/igny8_core/
    • Register in INSTALLED_APPS
  2. Define Models

    • Implement SAGBlueprint, SAGAttribute, SAGCluster, SectorAttributeTemplate
    • Add 5 new nullable fields to existing models (Site, Clusters, Tasks, Content, ContentIdeas)
    • Ensure all models inherit from correct base class (AccountBaseModel or base Model)
  3. Create Migrations

    • Run makemigrations sag
    • Manually verify for circular imports or dependencies
    • Create migration for modifications to existing models (Clusters, Tasks, Content, ContentIdeas in their respective apps; Site in igny8_core_auth)
  4. Implement Serializers

    • SAGBlueprintDetailSerializer (nested attributes & clusters)
    • SAGBlueprintListSerializer (lightweight)
    • SAGAttributeSerializer, SAGClusterSerializer
    • SectorAttributeTemplateSerializer
  5. Implement ViewSets

    • SAGBlueprintViewSet with confirm, archive, regenerate, health_check actions
    • SAGAttributeViewSet (nested under blueprints)
    • SAGClusterViewSet (nested under blueprints)
    • SectorAttributeTemplateViewSet (admin-only)
  6. Set Up URL Routing

    • Create sag/urls.py with routers
    • Include in main API router at /api/v1/sag/
  7. Django Admin Registration

    • Register all models with custom admin classes
    • Configure list_display, filters, search, fieldsets
  8. Service Layer

    • Implement blueprint_service.py (confirm, archive, create_new_version, get_active)
    • Implement health_service.py (compute_blueprint_health)
    • Implement cluster_service.py stubs
    • Implement attribute_service.py stubs
    • Implement template_service.py stubs
  9. AI Function Stubs

    • Create placeholder files in ai_functions/ (all raise NotImplementedError)
  10. Testing

    • Unit tests for models (validation, constraints)
    • Integration tests for ViewSets (CRUD, permissions)
    • Service layer tests

Phase 1B: Wizard & Migrations (Next Sprint)

  • Frontend React wizard for blueprint creation
  • Migration scripts for existing site data → SAG blueprints
  • Feature flag sag_enabled

5. ACCEPTANCE CRITERIA

Data Model

  • All 4 models created and migrated successfully
  • All 5 existing models have nullable SAG fields (Site, Clusters, Tasks, Content, ContentIdeas)
  • Unique constraints enforced (blueprint version, attribute slugs, cluster slugs, template industry/sector)
  • Foreign key cascades correct (blueprint → attributes/clusters)
  • All model methods and properties work as documented

API Endpoints

  • GET /api/v1/sag/blueprints/ (list all, paginated, filtered by account)
  • POST /api/v1/sag/blueprints/ (create draft blueprint)
  • GET /api/v1/sag/blueprints/{id}/ (retrieve with nested attributes/clusters)
  • PATCH /api/v1/sag/blueprints/{id}/ (partial update)
  • DELETE /api/v1/sag/blueprints/{id}/ (soft-delete or hard-delete with cascades)
  • POST /api/v1/sag/blueprints/{id}/confirm/ (draft → active)
  • POST /api/v1/sag/blueprints/{id}/archive/ (active → archived)
  • POST /api/v1/sag/blueprints/{id}/regenerate/ (create v+1)
  • POST /api/v1/sag/blueprints/{id}/health_check/ (compute score)
  • GET /api/v1/sag/blueprints/active_by_site/?site_id=
  • GET/POST /api/v1/sag/blueprints/{blueprint_id}/attributes/
  • GET/POST /api/v1/sag/blueprints/{blueprint_id}/clusters/
  • GET/POST /api/v1/sag/sector-templates/ (admin-only)
  • GET /api/v1/sag/sector-templates/by_industry_sector/?industry=&sector=

Serialization

  • All responses follow unified format: {success, data, message}
  • Nested serializers work (blueprint detail includes attributes & clusters)
  • Read-only fields enforced (id, timestamps, computed fields)
  • Validation rules applied (max_length, choices, unique_together)

Permissions & Tenancy

  • All account-based endpoints enforce IsAccountMember
  • Tenant isolation: users see only their account's blueprints
  • Admin-only endpoints require IsAdminUser
  • queryset filters automatically by account

Admin Interface

  • All models registered in Django admin
  • List views show key metrics (status, health, cluster count)
  • Search functional (by site name, industry, sector)
  • Filters functional (by status, source, type)
  • Read-only fields protected from editing

Service Layer

  • blueprint_service.confirm_blueprint() works (draft → active, Site ref updated)
  • blueprint_service.archive_blueprint() works (active → archived, Site ref cleared)
  • blueprint_service.create_new_version() creates copy with version+1
  • health_service.compute_blueprint_health() computes 0-100 score
  • All services handle exceptions gracefully

AI Function Stubs

  • All stubs present and raise NotImplementedError
  • No actual AI calls in Phase 1

Documentation

  • This document is self-contained and buildable
  • Code comments explain non-obvious logic
  • Django model docstrings present
  • ViewSet action docstrings present

6. CLAUDE CODE INSTRUCTIONS

How to Use This Document

This document contains everything Claude Code needs to build the sag/ app.

  1. Read this document end-to-end to understand the architecture
  2. Copy model definitions exactly into backend/igny8_core/sag/models.py
  3. Copy serializer code exactly into backend/igny8_core/sag/serializers.py
  4. Copy viewset code exactly into backend/igny8_core/sag/views.py
  5. Copy URL routing into backend/igny8_core/sag/urls.py
  6. Copy admin.py exactly as-is
  7. Create service files with code from Section 3.7
  8. Create AI function stubs from Section 3.8
  9. Create migration for existing model changes (Site in igny8_core_auth, Clusters/ContentIdeas in planner, Tasks/Content in writer)
  10. Run migrations on development database
  11. Test endpoints with Postman or curl
  12. Write unit & integration tests matching patterns in existing test suite

Key Commands

# Create app
python manage.py startapp sag igny8_core/

# Makemigrations
python manage.py makemigrations sag
python manage.py makemigrations igny8_core_auth planner writer  # For existing model changes

# Migrate
python manage.py migrate sag
python manage.py migrate

# Create superuser (for admin)
python manage.py createsuperuser

# Test
python manage.py test sag --verbosity=2

# Run development server
python manage.py runserver

Testing Endpoints (Postman/curl)

# 1. Get auth token (assuming DRF token auth)
curl -X POST http://localhost:8000/api/v1/auth/login/ \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "password"}'

# 2. Create blueprint
curl -X POST http://localhost:8000/api/v1/sag/blueprints/ \
  -H "Authorization: Token <YOUR_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "site": 42,
    "status": "draft",
    "source": "manual",
    "taxonomy_plan": {}
  }'

# 3. List blueprints
curl -X GET http://localhost:8000/api/v1/sag/blueprints/ \
  -H "Authorization: Token <YOUR_TOKEN>"

# 4. Confirm blueprint
curl -X POST http://localhost:8000/api/v1/sag/blueprints/<blueprint-id>/confirm/ \
  -H "Authorization: Token <YOUR_TOKEN>"

Integration with Existing Code

  • Import AccountBaseModel from igny8_core.common.models
  • Import AccountModelViewSet from igny8_core.common.views
  • Import IsAccountMember from igny8_core.common.permissions
  • Follow timezone handling as in existing models (use timezone.now())
  • Follow logging patterns from existing services
  • Use Celery for async if health_check needs to be background task (deferred to Phase 2)

Notes for Claude Code

  1. Models are self-contained — no circular imports
  2. Serializers follow DRF patterns already used in IGNY8
  3. ViewSets follow AccountModelViewSet pattern — inherit and override get_queryset()
  4. URL routing uses SimpleRouter — consistent with existing code
  5. All fields on existing models are nullable — no breaking changes
  6. All new endpoints return unified response format — {success, data, message}
  7. Service layer is optional in Phase 1 — focus on models & APIs; services can be minimal stubs
  8. AI functions are stubs only — Phase 2 will implement with actual AI

7. APPENDIX: Example Request/Response

Example: Create Blueprint

Request:

POST /api/v1/sag/blueprints/
Authorization: Token <token>

{
  "site": 42,
  "status": "draft",
  "source": "manual",
  "taxonomy_plan": {
    "primary_dimensions": ["color", "material"],
    "secondary_dimensions": ["size", "brand"]
  },
  "execution_priority": {
    "cluster_red_leather": 1,
    "cluster_blue_canvas": 2
  }
}

Response:

{
  "success": true,
  "data": {
    "id": 1,
    "site": 42,
    "account": 7,
    "version": 1,
    "status": "draft",
    "source": "manual",
    "attributes_json": {},
    "clusters_json": {},
    "taxonomy_plan": {
      "primary_dimensions": ["color", "material"],
      "secondary_dimensions": ["size", "brand"]
    },
    "execution_priority": {
      "cluster_red_leather": 1,
      "cluster_blue_canvas": 2
    },
    "internal_linking_map": {},
    "sag_health_score": 0.0,
    "total_clusters": 0,
    "total_keywords": 0,
    "total_content_planned": 0,
    "total_content_published": 0,
    "confirmed_at": null,
    "last_health_check": null,
    "attributes": [],
    "clusters": [],
    "created_at": "2026-03-23T10:00:00Z",
    "updated_at": "2026-03-23T10:00:00Z"
  },
  "message": null
}

Example: Confirm Blueprint

Request:

POST /api/v1/sag/blueprints/1/confirm/
Authorization: Token <token>

Response:

{
  "success": true,
  "data": {
    "id": 1,
    "site": 42,
    "version": 1,
    "status": "active",
    "confirmed_at": "2026-03-23T10:05:00Z",
    ...
  },
  "message": "Blueprint v1 activated."
}

Summary

This document defines the complete SAG Data Foundation for IGNY8 Phase 1:

  • 4 new models for blueprints, attributes, clusters, and templates
  • 5 modified existing models with nullable SAG references
  • Full CRUD API with custom actions (confirm, archive, regenerate, health_check)
  • Service layer for complex operations
  • AI function stubs for Phase 23 integration
  • Admin interface for management

All code is production-ready, follows IGNY8 patterns, and is backward-compatible. The next phase (Phase 1B) will add the wizard UI and migration scripts.

Build Status: Ready for Claude Code implementation.