1921 lines
61 KiB
Markdown
1921 lines
61 KiB
Markdown
# 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=<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>§or=<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`
|
||
|
||
```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/<int:blueprint_id>/', 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=<int>
|
||
- [ ] 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=<str>§or=<str>
|
||
|
||
### 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 <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:**
|
||
```json
|
||
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:**
|
||
```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 <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.
|
||
|