# 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 ```json { "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 2–3 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 2–3) - 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 2–3) │ ├── attribute_extraction.py # AI: Extract values from content (Phase 2–3) │ ├── attribute_population.py # AI: Populate values into blueprint (Phase 2–3) │ ├── cluster_formation.py # AI: Form clusters from keywords (Phase 2–3) │ ├── keyword_generation.py # AI: Generate keywords per cluster (Phase 2–3) │ └── content_planning.py # AI: Plan supporting content (Phase 2–3) ├── 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:** ```python 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:** ```python 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:** ```python 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:** ```python 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`) ```python 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`) ```python 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`) ```python 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`) ```python 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`) ```python 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` ```python 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` ```python 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=""" 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=§or= """ 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` ```python 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//', include(blueprints_router.urls)), ] ``` **Root URLs Integration** (in `backend/igny8_core/urls.py` or `api/v1/urls.py`): ```python urlpatterns = [ path('api/v1/sag/', include('sag.urls')), ] ``` --- ### 3.6 Admin Configuration **File:** `sag/admin.py` ```python 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` ```python 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` ```python 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` ```python 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` ```python 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` ```python 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` ```python 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` ```python """ Phase 2–3: 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` ```python """ Phase 2–3: 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` ```python """ Phase 2–3: 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` ```python """ 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` ```python # Stubs for Phase 2–3 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=§or= ### 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 ```bash # 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) ```bash # 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 " \ -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 " # 4. Confirm blueprint curl -X POST http://localhost:8000/api/v1/sag/blueprints//confirm/ \ -H "Authorization: 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:** ```json POST /api/v1/sag/blueprints/ Authorization: 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:** ```json { "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:** ```json POST /api/v1/sag/blueprints/1/confirm/ Authorization: Token ``` **Response:** ```json { "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 2–3 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.