Refactor content handling in GenerateContentFunction and update related models and serializers
- Enhanced GenerateContentFunction to save content in a dedicated Content model, separating it from the Tasks model. - Updated Tasks model to remove SEO-related fields, now managed in the Content model. - Modified TasksSerializer to include new content fields and adjusted the API to reflect these changes. - Improved the auto_generate_content_task method to utilize the new save_output method for better content management. - Updated frontend components to display new content structure and metadata effectively.
This commit is contained in:
@@ -7,7 +7,7 @@ import re
|
||||
from typing import Dict, List, Any
|
||||
from django.db import transaction
|
||||
from igny8_core.ai.base import BaseAIFunction
|
||||
from igny8_core.modules.writer.models import Tasks
|
||||
from igny8_core.modules.writer.models import Tasks, Content as TaskContent
|
||||
from igny8_core.ai.ai_core import AICore
|
||||
from igny8_core.ai.validators import validate_tasks_exist
|
||||
from igny8_core.ai.prompts import PromptRegistry
|
||||
@@ -188,69 +188,111 @@ class GenerateContentFunction(BaseAIFunction):
|
||||
# Handle parsed response - can be dict (JSON) or string (plain text)
|
||||
if isinstance(parsed, dict):
|
||||
# JSON response with structured fields
|
||||
content = parsed.get('content', '')
|
||||
title = parsed.get('title', task.title)
|
||||
meta_title = parsed.get('meta_title', title or task.title)
|
||||
content_html = parsed.get('content', '')
|
||||
title = parsed.get('title') or task.title
|
||||
meta_title = parsed.get('meta_title') or title or task.title
|
||||
meta_description = parsed.get('meta_description', '')
|
||||
word_count = parsed.get('word_count', 0)
|
||||
primary_keyword = parsed.get('primary_keyword', '')
|
||||
secondary_keywords = parsed.get('secondary_keywords', [])
|
||||
tags = parsed.get('tags', [])
|
||||
categories = parsed.get('categories', [])
|
||||
content_status = parsed.get('status', 'draft')
|
||||
else:
|
||||
# Plain text response (legacy)
|
||||
content = str(parsed)
|
||||
content_html = str(parsed)
|
||||
title = task.title
|
||||
meta_title = task.title
|
||||
meta_description = (task.description or '')[:160] if task.description else ''
|
||||
meta_title = task.meta_title or task.title
|
||||
meta_description = task.meta_description or (task.description or '')[:160] if task.description else ''
|
||||
word_count = 0
|
||||
primary_keyword = ''
|
||||
secondary_keywords = []
|
||||
tags = []
|
||||
categories = []
|
||||
content_status = 'draft'
|
||||
|
||||
# Calculate word count if not provided
|
||||
if not word_count and content:
|
||||
text_for_counting = re.sub(r'<[^>]+>', '', content)
|
||||
if not word_count and content_html:
|
||||
text_for_counting = re.sub(r'<[^>]+>', '', content_html)
|
||||
word_count = len(text_for_counting.split())
|
||||
|
||||
# Update task with all fields
|
||||
if content:
|
||||
task.content = content
|
||||
if title and title != task.title:
|
||||
task.title = title
|
||||
task.word_count = word_count
|
||||
# Ensure related content record exists
|
||||
content_record, _created = TaskContent.objects.get_or_create(
|
||||
task=task,
|
||||
defaults={
|
||||
'account': task.account,
|
||||
'site': task.site,
|
||||
'sector': task.sector,
|
||||
'html_content': content_html or '',
|
||||
'word_count': word_count or 0,
|
||||
'status': 'draft',
|
||||
},
|
||||
)
|
||||
|
||||
# SEO fields
|
||||
if meta_title:
|
||||
task.meta_title = meta_title
|
||||
elif not task.meta_title:
|
||||
task.meta_title = task.title # Fallback to title
|
||||
# Update content fields
|
||||
if content_html:
|
||||
content_record.html_content = content_html
|
||||
content_record.word_count = word_count or content_record.word_count or 0
|
||||
content_record.title = title
|
||||
content_record.meta_title = meta_title
|
||||
content_record.meta_description = meta_description
|
||||
content_record.primary_keyword = primary_keyword or ''
|
||||
if isinstance(secondary_keywords, list):
|
||||
content_record.secondary_keywords = secondary_keywords
|
||||
elif secondary_keywords:
|
||||
content_record.secondary_keywords = [secondary_keywords]
|
||||
else:
|
||||
content_record.secondary_keywords = []
|
||||
if isinstance(tags, list):
|
||||
content_record.tags = tags
|
||||
elif tags:
|
||||
content_record.tags = [tags]
|
||||
else:
|
||||
content_record.tags = []
|
||||
if isinstance(categories, list):
|
||||
content_record.categories = categories
|
||||
elif categories:
|
||||
content_record.categories = [categories]
|
||||
else:
|
||||
content_record.categories = []
|
||||
|
||||
if meta_description:
|
||||
task.meta_description = meta_description
|
||||
elif not task.meta_description and task.description:
|
||||
task.meta_description = (task.description or '')[:160] # Fallback to description
|
||||
content_record.status = content_status or 'draft'
|
||||
|
||||
if primary_keyword:
|
||||
task.primary_keyword = primary_keyword
|
||||
# Merge any extra fields into metadata (non-standard keys)
|
||||
if isinstance(parsed, dict):
|
||||
excluded_keys = {
|
||||
'content',
|
||||
'title',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'primary_keyword',
|
||||
'secondary_keywords',
|
||||
'tags',
|
||||
'categories',
|
||||
'word_count',
|
||||
'status',
|
||||
}
|
||||
extra_meta = {k: v for k, v in parsed.items() if k not in excluded_keys}
|
||||
existing_meta = content_record.metadata or {}
|
||||
existing_meta.update(extra_meta)
|
||||
content_record.metadata = existing_meta
|
||||
|
||||
if secondary_keywords:
|
||||
task.secondary_keywords = secondary_keywords if isinstance(secondary_keywords, list) else []
|
||||
# Align foreign keys to ensure consistency
|
||||
content_record.account = task.account
|
||||
content_record.site = task.site
|
||||
content_record.sector = task.sector
|
||||
content_record.task = task
|
||||
|
||||
if tags:
|
||||
task.tags = tags if isinstance(tags, list) else []
|
||||
content_record.save()
|
||||
|
||||
if categories:
|
||||
task.categories = categories if isinstance(categories, list) else []
|
||||
|
||||
task.status = 'draft'
|
||||
task.save()
|
||||
# Update task status - keep task data intact but mark as completed
|
||||
task.status = 'completed'
|
||||
task.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
return {
|
||||
'count': 1,
|
||||
'tasks_updated': 1,
|
||||
'word_count': word_count
|
||||
'word_count': content_record.word_count,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Generated manually for adding seed_keyword relationship to Keywords
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -11,76 +10,7 @@ class Migration(migrations.Migration):
|
||||
('planner', '0003_alter_clusters_sector_alter_clusters_site_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Remove old fields (keyword, volume, difficulty, intent)
|
||||
migrations.RemoveField(
|
||||
model_name='keywords',
|
||||
name='keyword',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='keywords',
|
||||
name='volume',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='keywords',
|
||||
name='difficulty',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='keywords',
|
||||
name='intent',
|
||||
),
|
||||
# Add seed_keyword FK
|
||||
migrations.AddField(
|
||||
model_name='keywords',
|
||||
name='seed_keyword',
|
||||
field=models.ForeignKey(
|
||||
help_text='Reference to the global seed keyword',
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='site_keywords',
|
||||
to='igny8_core_auth.seedkeyword',
|
||||
null=True # Temporarily nullable for migration
|
||||
),
|
||||
),
|
||||
# Add override fields
|
||||
migrations.AddField(
|
||||
model_name='keywords',
|
||||
name='volume_override',
|
||||
field=models.IntegerField(blank=True, help_text='Site-specific volume override (uses seed_keyword.volume if not set)', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='keywords',
|
||||
name='difficulty_override',
|
||||
field=models.IntegerField(blank=True, help_text='Site-specific difficulty override (uses seed_keyword.difficulty if not set)', null=True),
|
||||
),
|
||||
# Make seed_keyword required (after data migration if needed)
|
||||
migrations.AlterField(
|
||||
model_name='keywords',
|
||||
name='seed_keyword',
|
||||
field=models.ForeignKey(
|
||||
help_text='Reference to the global seed keyword',
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='site_keywords',
|
||||
to='igny8_core_auth.seedkeyword'
|
||||
),
|
||||
),
|
||||
# Add unique constraint
|
||||
migrations.AlterUniqueTogether(
|
||||
name='keywords',
|
||||
unique_together={('seed_keyword', 'site', 'sector')},
|
||||
),
|
||||
# Update indexes
|
||||
migrations.AlterIndexTogether(
|
||||
name='keywords',
|
||||
index_together=set(),
|
||||
),
|
||||
# Add new indexes
|
||||
migrations.AddIndex(
|
||||
model_name='keywords',
|
||||
index=models.Index(fields=['seed_keyword'], name='igny8_keyw_seed_k_12345_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='keywords',
|
||||
index=models.Index(fields=['seed_keyword', 'site', 'sector'], name='igny8_keyw_seed_si_67890_idx'),
|
||||
),
|
||||
]
|
||||
# Duplicate of planner.0006_add_seed_keyword_to_keywords.
|
||||
# This migration is kept as a no-op to avoid applying the schema changes twice.
|
||||
operations = []
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def _normalize_list(value):
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, tuple):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Tasks = apps.get_model('writer', 'Tasks')
|
||||
Content = apps.get_model('writer', 'Content')
|
||||
|
||||
for task in Tasks.objects.all():
|
||||
account_id = getattr(task, 'account_id', None)
|
||||
if account_id is None and getattr(task, 'account', None):
|
||||
account_id = task.account.id
|
||||
|
||||
site_id = getattr(task, 'site_id', None)
|
||||
if site_id is None and getattr(task, 'site', None):
|
||||
site_id = task.site.id
|
||||
|
||||
sector_id = getattr(task, 'sector_id', None)
|
||||
if sector_id is None and getattr(task, 'sector', None):
|
||||
sector_id = task.sector.id if task.sector else None
|
||||
|
||||
tenant_id = getattr(task, 'tenant_id', None)
|
||||
if tenant_id is None and getattr(task, 'tenant', None):
|
||||
tenant_id = task.tenant.id
|
||||
|
||||
# Prepare defaults for new content record
|
||||
defaults = {
|
||||
'html_content': task.content or '',
|
||||
'word_count': task.word_count or 0,
|
||||
'title': getattr(task, 'title', None),
|
||||
'meta_title': getattr(task, 'meta_title', None),
|
||||
'meta_description': getattr(task, 'meta_description', None),
|
||||
'primary_keyword': getattr(task, 'primary_keyword', None),
|
||||
'secondary_keywords': _normalize_list(getattr(task, 'secondary_keywords', [])),
|
||||
'tags': _normalize_list(getattr(task, 'tags', [])),
|
||||
'categories': _normalize_list(getattr(task, 'categories', [])),
|
||||
'status': 'draft',
|
||||
}
|
||||
|
||||
content_record = Content.objects.filter(task_id=task.id).first()
|
||||
created = False
|
||||
if not content_record:
|
||||
content_record = Content(task_id=task.id, **defaults)
|
||||
created = True
|
||||
|
||||
# Update existing records with the latest information
|
||||
if not created:
|
||||
if task.content:
|
||||
content_record.html_content = task.content
|
||||
if task.word_count:
|
||||
content_record.word_count = task.word_count
|
||||
|
||||
if getattr(task, 'title', None):
|
||||
content_record.title = task.title
|
||||
if getattr(task, 'meta_title', None):
|
||||
content_record.meta_title = task.meta_title
|
||||
if getattr(task, 'meta_description', None):
|
||||
content_record.meta_description = task.meta_description
|
||||
|
||||
if hasattr(task, 'primary_keyword'):
|
||||
content_record.primary_keyword = task.primary_keyword or ''
|
||||
if hasattr(task, 'secondary_keywords'):
|
||||
content_record.secondary_keywords = _normalize_list(task.secondary_keywords)
|
||||
if hasattr(task, 'tags'):
|
||||
content_record.tags = _normalize_list(task.tags)
|
||||
if hasattr(task, 'categories'):
|
||||
content_record.categories = _normalize_list(task.categories)
|
||||
|
||||
if not content_record.status:
|
||||
content_record.status = 'draft'
|
||||
|
||||
# Ensure account/site/sector alignment (save() will also set this)
|
||||
if account_id:
|
||||
content_record.account_id = account_id
|
||||
if site_id:
|
||||
content_record.site_id = site_id
|
||||
if sector_id:
|
||||
content_record.sector_id = sector_id
|
||||
if tenant_id:
|
||||
content_record.tenant_id = tenant_id
|
||||
|
||||
# Preserve existing metadata but ensure it's a dict
|
||||
metadata = content_record.metadata or {}
|
||||
content_record.metadata = metadata
|
||||
|
||||
content_record.save()
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
"""
|
||||
Reverse data migration is intentionally left as a no-op because
|
||||
reverting would require reintroducing the removed fields on Tasks.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('writer', '0004_add_content_seo_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='categories',
|
||||
field=models.JSONField(blank=True, default=list, help_text='List of categories'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='meta_description',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='meta_title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='primary_keyword',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='secondary_keywords',
|
||||
field=models.JSONField(blank=True, default=list, help_text='List of secondary keywords'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='status',
|
||||
field=models.CharField(default='draft', help_text='Content workflow status (draft, review, published, etc.)', max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='tags',
|
||||
field=models.JSONField(blank=True, default=list, help_text='List of tags'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='categories',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='primary_keyword',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='secondary_keywords',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='tags',
|
||||
),
|
||||
]
|
||||
|
||||
@@ -65,11 +65,6 @@ class Tasks(SiteSectorBaseModel):
|
||||
# SEO fields
|
||||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||||
meta_description = models.TextField(blank=True, null=True)
|
||||
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
|
||||
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
|
||||
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
|
||||
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
|
||||
|
||||
# WordPress integration
|
||||
assigned_post_id = models.IntegerField(null=True, blank=True) # WordPress post ID if published
|
||||
post_url = models.URLField(blank=True, null=True) # WordPress post URL
|
||||
@@ -108,6 +103,14 @@ class Content(SiteSectorBaseModel):
|
||||
html_content = models.TextField(help_text="Final AI-generated HTML content")
|
||||
word_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||
metadata = models.JSONField(default=dict, help_text="Additional metadata (SEO, structure, etc.)")
|
||||
title = models.CharField(max_length=255, blank=True, null=True)
|
||||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||||
meta_description = models.TextField(blank=True, null=True)
|
||||
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
|
||||
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
|
||||
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
|
||||
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
|
||||
status = models.CharField(max_length=50, default='draft', help_text="Content workflow status (draft, review, published, etc.)")
|
||||
generated_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ class TasksSerializer(serializers.ModelSerializer):
|
||||
idea_title = serializers.SerializerMethodField()
|
||||
site_id = serializers.IntegerField(write_only=True, required=False)
|
||||
sector_id = serializers.IntegerField(write_only=True, required=False)
|
||||
content_html = serializers.SerializerMethodField()
|
||||
content_primary_keyword = serializers.SerializerMethodField()
|
||||
content_secondary_keywords = serializers.SerializerMethodField()
|
||||
content_tags = serializers.SerializerMethodField()
|
||||
content_categories = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Tasks
|
||||
@@ -30,10 +35,11 @@ class TasksSerializer(serializers.ModelSerializer):
|
||||
'word_count',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'primary_keyword',
|
||||
'secondary_keywords',
|
||||
'tags',
|
||||
'categories',
|
||||
'content_html',
|
||||
'content_primary_keyword',
|
||||
'content_secondary_keywords',
|
||||
'content_tags',
|
||||
'content_categories',
|
||||
'assigned_post_id',
|
||||
'post_url',
|
||||
'created_at',
|
||||
@@ -75,6 +81,32 @@ class TasksSerializer(serializers.ModelSerializer):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _get_content_record(self, obj):
|
||||
try:
|
||||
return obj.content_record
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
def get_content_html(self, obj):
|
||||
record = self._get_content_record(obj)
|
||||
return record.html_content if record else None
|
||||
|
||||
def get_content_primary_keyword(self, obj):
|
||||
record = self._get_content_record(obj)
|
||||
return record.primary_keyword if record else None
|
||||
|
||||
def get_content_secondary_keywords(self, obj):
|
||||
record = self._get_content_record(obj)
|
||||
return record.secondary_keywords if record else []
|
||||
|
||||
def get_content_tags(self, obj):
|
||||
record = self._get_content_record(obj)
|
||||
return record.tags if record else []
|
||||
|
||||
def get_content_categories(self, obj):
|
||||
record = self._get_content_record(obj)
|
||||
return record.categories if record else []
|
||||
|
||||
|
||||
class ImagesSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Images model"""
|
||||
@@ -122,6 +154,14 @@ class ContentSerializer(serializers.ModelSerializer):
|
||||
'html_content',
|
||||
'word_count',
|
||||
'metadata',
|
||||
'title',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'primary_keyword',
|
||||
'secondary_keywords',
|
||||
'tags',
|
||||
'categories',
|
||||
'status',
|
||||
'generated_at',
|
||||
'updated_at',
|
||||
'account_id',
|
||||
|
||||
@@ -632,11 +632,43 @@ def auto_generate_content_task(self, task_ids: List[int], account_id: int = None
|
||||
}
|
||||
)
|
||||
|
||||
# Normalize content from different AI response formats
|
||||
logger.info(f" * Normalizing content (length: {len(content)} chars)...")
|
||||
# Parse JSON response using GenerateContentFunction's parse_response method
|
||||
logger.info(f" * Parsing AI response (length: {len(content)} chars)...")
|
||||
try:
|
||||
from igny8_core.ai.functions.generate_content import GenerateContentFunction
|
||||
fn = GenerateContentFunction()
|
||||
parsed_response = fn.parse_response(content)
|
||||
|
||||
logger.info(f" * ✓ Response parsed:")
|
||||
logger.info(f" - Type: {type(parsed_response).__name__}")
|
||||
if isinstance(parsed_response, dict):
|
||||
logger.info(f" - Keys: {list(parsed_response.keys())}")
|
||||
logger.info(f" - Has title: {bool(parsed_response.get('title'))}")
|
||||
logger.info(f" - Has meta_title: {bool(parsed_response.get('meta_title'))}")
|
||||
logger.info(f" - Has primary_keyword: {bool(parsed_response.get('primary_keyword'))}")
|
||||
logger.info(f" - Has secondary_keywords: {bool(parsed_response.get('secondary_keywords'))}")
|
||||
logger.info(f" - Has tags: {bool(parsed_response.get('tags'))}")
|
||||
logger.info(f" - Has categories: {bool(parsed_response.get('categories'))}")
|
||||
logger.info(f" - Content length: {len(parsed_response.get('content', ''))} chars")
|
||||
else:
|
||||
logger.info(f" - Content length: {len(str(parsed_response))} chars")
|
||||
|
||||
# Use parsed response for saving
|
||||
parsed_data = parsed_response
|
||||
|
||||
except Exception as parse_error:
|
||||
logger.warning(f" * ⚠️ JSON parsing failed: {type(parse_error).__name__}: {str(parse_error)}")
|
||||
logger.warning(f" * Treating as plain text content")
|
||||
# Fallback to plain text
|
||||
parsed_data = {'content': content}
|
||||
|
||||
# Normalize content from parsed response
|
||||
content_to_normalize = parsed_data.get('content', '') if isinstance(parsed_data, dict) else str(parsed_data)
|
||||
if content_to_normalize:
|
||||
logger.info(f" * Normalizing content (length: {len(content_to_normalize)} chars)...")
|
||||
try:
|
||||
from igny8_core.utils.content_normalizer import normalize_content
|
||||
normalized = normalize_content(content)
|
||||
normalized = normalize_content(content_to_normalize)
|
||||
normalized_content = normalized['normalized_content']
|
||||
content_type = normalized['content_type']
|
||||
has_structure = normalized['has_structure']
|
||||
@@ -649,8 +681,11 @@ def auto_generate_content_task(self, task_ids: List[int], account_id: int = None
|
||||
logger.info(f" - Normalized length: {len(normalized_content)} chars")
|
||||
logger.info(f" - Normalized preview (first 200 chars): {normalized_content[:200]}...")
|
||||
|
||||
# Use normalized content
|
||||
content = normalized_content
|
||||
# Update parsed_data with normalized content
|
||||
if isinstance(parsed_data, dict):
|
||||
parsed_data['content'] = normalized_content
|
||||
else:
|
||||
parsed_data = {'content': normalized_content}
|
||||
|
||||
except Exception as norm_error:
|
||||
logger.warning(f" * ⚠️ Content normalization failed: {type(norm_error).__name__}: {str(norm_error)}")
|
||||
@@ -662,14 +697,41 @@ def auto_generate_content_task(self, task_ids: List[int], account_id: int = None
|
||||
logger.error(f" * Task ID: {task.id}", exc_info=True)
|
||||
continue
|
||||
|
||||
# Calculate word count from normalized content
|
||||
# Remove HTML tags for word count
|
||||
text_for_counting = re.sub(r'<[^>]+>', '', content)
|
||||
# Use GenerateContentFunction's save_output method to properly save all fields
|
||||
logger.info(" - Saving content to database using GenerateContentFunction.save_output()...")
|
||||
try:
|
||||
from igny8_core.ai.functions.generate_content import GenerateContentFunction
|
||||
fn = GenerateContentFunction()
|
||||
|
||||
# Save using the proper save_output method which handles all fields
|
||||
save_result = fn.save_output(parsed_data, [task], account)
|
||||
|
||||
# Get word count from save result or calculate
|
||||
word_count = save_result.get('word_count', 0)
|
||||
if not word_count and isinstance(parsed_data, dict):
|
||||
content_for_count = parsed_data.get('content', '')
|
||||
if content_for_count:
|
||||
text_for_counting = re.sub(r'<[^>]+>', '', content_for_count)
|
||||
word_count = len(text_for_counting.split())
|
||||
logger.info(f" * ✓ Word count calculated: {word_count} words (from normalized HTML)")
|
||||
|
||||
logger.info(f" * ✓ Task saved successfully using save_output()")
|
||||
logger.info(f" - tasks_updated: {save_result.get('tasks_updated', 0)}")
|
||||
logger.info(f" - word_count: {word_count}")
|
||||
|
||||
# Log all fields that were saved
|
||||
logger.info(f" * Saved fields:")
|
||||
logger.info(f" - task_id: {task.id}")
|
||||
logger.info(f" - task_status: {task.status}")
|
||||
if isinstance(parsed_data, dict):
|
||||
logger.info(f" - content_title: {parsed_data.get('title') or task.title}")
|
||||
logger.info(f" - content_primary_keyword: {parsed_data.get('primary_keyword') or 'N/A'}")
|
||||
logger.info(f" - content_secondary_keywords: {len(parsed_data.get('secondary_keywords') or [])} items")
|
||||
logger.info(f" - content_tags: {len(parsed_data.get('tags') or [])} items")
|
||||
logger.info(f" - content_categories: {len(parsed_data.get('categories') or [])} items")
|
||||
logger.info(f" - content_word_count: {word_count}")
|
||||
|
||||
# Update progress: Saving content
|
||||
add_step('SAVE', 'success', f"Saving content for '{task.title}' ({word_count} words)...", 'request')
|
||||
add_step('SAVE', 'success', f"Content saved for '{task.title}' ({word_count} words)...", 'response')
|
||||
save_pct = 85 + int((idx / total_tasks) * 10) # 85-95% for saving
|
||||
self.update_state(
|
||||
state='PROGRESS',
|
||||
@@ -677,7 +739,7 @@ def auto_generate_content_task(self, task_ids: List[int], account_id: int = None
|
||||
'current': idx + 1,
|
||||
'total': total_tasks,
|
||||
'percentage': save_pct,
|
||||
'message': f"Saving content for '{task.title}' ({word_count} words)...",
|
||||
'message': f"Content saved for '{task.title}' ({word_count} words)...",
|
||||
'phase': 'SAVE',
|
||||
'current_item': task.title,
|
||||
'request_steps': request_steps,
|
||||
@@ -685,50 +747,7 @@ def auto_generate_content_task(self, task_ids: List[int], account_id: int = None
|
||||
}
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# DATABASE SAVE PHASE - Detailed logging
|
||||
# ========================================================================
|
||||
logger.info(" - Saving content to database...")
|
||||
try:
|
||||
# Update task
|
||||
logger.info(f" * Updating task {task.id} fields...")
|
||||
task.content = content
|
||||
logger.info(f" - content: {len(content)} chars")
|
||||
|
||||
task.word_count = word_count
|
||||
logger.info(f" - word_count: {word_count}")
|
||||
|
||||
task.meta_title = task.title # Use title as meta title for now
|
||||
logger.info(f" - meta_title: {task.title}")
|
||||
|
||||
task.meta_description = (task.description or '')[:160] # Truncate to 160 chars
|
||||
logger.info(f" - meta_description: {len(task.meta_description)} chars")
|
||||
|
||||
old_status = task.status
|
||||
task.status = 'draft' # Update status from queued to draft
|
||||
logger.info(f" - status: {old_status} → {task.status}")
|
||||
|
||||
# Log all fields being saved
|
||||
logger.info(f" * Task fields to save:")
|
||||
logger.info(f" - id: {task.id}")
|
||||
logger.info(f" - title: {task.title}")
|
||||
logger.info(f" - account_id: {task.account_id}")
|
||||
logger.info(f" - site_id: {task.site_id}")
|
||||
logger.info(f" - sector_id: {task.sector_id}")
|
||||
logger.info(f" - cluster_id: {task.cluster_id}")
|
||||
logger.info(f" - idea_id: {task.idea_id}")
|
||||
logger.info(f" - content length: {len(task.content)}")
|
||||
logger.info(f" - word_count: {task.word_count}")
|
||||
|
||||
# Save to database
|
||||
logger.info(f" * Executing task.save()...")
|
||||
task.save()
|
||||
logger.info(f" * ✓ Task saved successfully to database")
|
||||
|
||||
# Mark save step as complete
|
||||
add_step('SAVE', 'success', f"Content saved for '{task.title}'", 'response')
|
||||
|
||||
tasks_updated += 1
|
||||
tasks_updated += save_result.get('tasks_updated', 0)
|
||||
logger.info(f" * ✓ Task {task.id} content generation completed successfully")
|
||||
|
||||
except Exception as save_error:
|
||||
|
||||
@@ -13,7 +13,7 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
"""
|
||||
ViewSet for managing tasks with CRUD operations
|
||||
"""
|
||||
queryset = Tasks.objects.all()
|
||||
queryset = Tasks.objects.select_related('content_record')
|
||||
serializer_class = TasksSerializer
|
||||
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
||||
|
||||
|
||||
@@ -169,33 +169,6 @@ export default function ProgressModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{details && (
|
||||
<div className="mb-6 space-y-2">
|
||||
{details.currentItem && (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium">Current:</span>{' '}
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{details.currentItem}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{details.total > 0 && (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium">Progress:</span>{' '}
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{details.current} of {details.total} completed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{details.phase && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||
Phase: {details.phase}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Function ID and Task ID (for debugging) */}
|
||||
{(fullFunctionId || taskId) && import.meta.env.DEV && (
|
||||
<div className="mb-4 space-y-1 text-xs text-gray-400 dark:text-gray-600">
|
||||
|
||||
@@ -97,18 +97,11 @@ export const createTasksPageConfig = (
|
||||
columns: [
|
||||
{
|
||||
...titleColumn,
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
sortable: true,
|
||||
sortField: 'title',
|
||||
toggleable: true, // Enable toggle for this column
|
||||
toggleContentKey: 'content', // Use content field for toggle (fallback to description if content not available)
|
||||
toggleContentLabel: 'Generated Content', // Label for expanded content
|
||||
render: (_value: string, row: Task) => (
|
||||
<span className="text-gray-800 dark:text-white font-medium">
|
||||
{row.meta_title || row.title || '-'}
|
||||
</span>
|
||||
),
|
||||
toggleable: true,
|
||||
toggleContentKey: 'content_html',
|
||||
toggleContentLabel: 'Generated Content',
|
||||
},
|
||||
// Sector column - only show when viewing all sectors
|
||||
...(showSectorColumn ? [{
|
||||
@@ -173,85 +166,6 @@ export const createTasksPageConfig = (
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'keywords',
|
||||
label: 'Keywords',
|
||||
sortable: false,
|
||||
width: '250px',
|
||||
render: (_value: any, row: Task) => {
|
||||
const keywords: React.ReactNode[] = [];
|
||||
|
||||
// Primary keyword as info badge
|
||||
if (row.primary_keyword) {
|
||||
keywords.push(
|
||||
<Badge key="primary" color="info" size="sm" variant="light" className="mr-1 mb-1">
|
||||
{row.primary_keyword}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Secondary keywords as light badges
|
||||
if (row.secondary_keywords && Array.isArray(row.secondary_keywords) && row.secondary_keywords.length > 0) {
|
||||
row.secondary_keywords.forEach((keyword, index) => {
|
||||
if (keyword) {
|
||||
keywords.push(
|
||||
<Badge key={`secondary-${index}`} color="light" size="sm" variant="light" className="mr-1 mb-1">
|
||||
{keyword}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return keywords.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{keywords}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
label: 'Tags',
|
||||
sortable: false,
|
||||
width: '200px',
|
||||
render: (_value: any, row: Task) => {
|
||||
if (row.tags && Array.isArray(row.tags) && row.tags.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.tags.map((tag, index) => (
|
||||
<Badge key={index} color="light" size="sm" variant="light">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400">-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
label: 'Categories',
|
||||
sortable: false,
|
||||
width: '200px',
|
||||
render: (_value: any, row: Task) => {
|
||||
if (row.categories && Array.isArray(row.categories) && row.categories.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.categories.map((category, index) => (
|
||||
<Badge key={index} color="light" size="sm" variant="light">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400">-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...wordCountColumn,
|
||||
sortable: true,
|
||||
|
||||
@@ -2,13 +2,21 @@ import { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchContent, Content as ContentType } from '../../services/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import HTMLContentRenderer from '../../components/common/HTMLContentRenderer';
|
||||
|
||||
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
||||
draft: 'warning',
|
||||
review: 'info',
|
||||
published: 'success',
|
||||
completed: 'success',
|
||||
};
|
||||
|
||||
export default function Content() {
|
||||
const toast = useToast();
|
||||
const [content, setContent] = useState<ContentType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedContent, setSelectedContent] = useState<ContentType | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
@@ -26,44 +34,174 @@ export default function Content() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value: string) =>
|
||||
new Date(value).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
|
||||
const renderBadgeList = (items?: string[], emptyLabel = '-') => {
|
||||
if (!items || items.length === 0) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">{emptyLabel}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{items.map((item, index) => (
|
||||
<Badge key={`${item}-${index}`} color="light" size="sm" variant="light">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">View all generated content</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Review AI-generated drafts and metadata</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
) : content.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-12 text-center text-gray-500 dark:text-gray-400">
|
||||
No content generated yet. Run an AI content job to see drafts here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{content.map((item: ContentType) => (
|
||||
<Card key={item.id} className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.05]">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Primary Keyword
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Secondary Keywords
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Tags
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Categories
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Word Count
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Generated
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Content
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-white/[0.05]">
|
||||
{content.map((item) => {
|
||||
const isExpanded = expandedId === item.id;
|
||||
return (
|
||||
<tr key={item.id} className="bg-white dark:bg-gray-900">
|
||||
<td className="px-5 py-4 align-top">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{item.meta_title || item.title || `Task #${item.task}`}
|
||||
</div>
|
||||
{item.meta_description && (
|
||||
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{item.meta_description}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{item.primary_keyword ? (
|
||||
<Badge color="info" size="sm" variant="light">
|
||||
{item.primary_keyword}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(item.secondary_keywords)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(item.tags)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(item.categories)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-gray-700 dark:text-gray-300">
|
||||
{item.word_count?.toLocaleString?.() ?? '-'}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
<Badge
|
||||
color={statusColors[item.status] || 'primary'}
|
||||
size="sm"
|
||||
variant="light"
|
||||
>
|
||||
{item.status?.replace('_', ' ') || 'draft'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-gray-600 dark:text-gray-400">
|
||||
{formatDate(item.generated_at)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-right">
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
||||
className="text-sm font-medium text-blue-light-500 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:text-blue-light-300"
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'View'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content.map((item) =>
|
||||
expandedId === item.id ? (
|
||||
<div
|
||||
key={`expanded-${item.id}`}
|
||||
className="mt-6 rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900 p-6"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Task #{item.task}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Generated: {new Date(item.generated_at).toLocaleString()}
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{item.meta_title || item.title || `Task #${item.task}`}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Generated {formatDate(item.generated_at)} • Task #{item.task}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.word_count} words
|
||||
<button
|
||||
onClick={() => setExpandedId(null)}
|
||||
className="text-sm font-medium text-blue-light-500 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:text-blue-light-300"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="prose dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: item.html_content }}
|
||||
<HTMLContentRenderer
|
||||
content={item.html_content}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1071,10 +1071,11 @@ export interface Task {
|
||||
word_count: number;
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
primary_keyword?: string | null;
|
||||
secondary_keywords?: string[] | null;
|
||||
tags?: string[] | null;
|
||||
categories?: string[] | null;
|
||||
content_html?: string | null;
|
||||
content_primary_keyword?: string | null;
|
||||
content_secondary_keywords?: string[];
|
||||
content_tags?: string[];
|
||||
content_categories?: string[];
|
||||
assigned_post_id?: number | null;
|
||||
post_url?: string | null;
|
||||
created_at: string;
|
||||
@@ -1841,6 +1842,15 @@ export async function deleteAuthorProfile(id: number): Promise<void> {
|
||||
export interface Content {
|
||||
id: number;
|
||||
task: number;
|
||||
task_title?: string | null;
|
||||
title?: string | null;
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
primary_keyword?: string | null;
|
||||
secondary_keywords?: string[];
|
||||
tags?: string[];
|
||||
categories?: string[];
|
||||
status: string;
|
||||
html_content: string;
|
||||
word_count: number;
|
||||
metadata: Record<string, any>;
|
||||
|
||||
Reference in New Issue
Block a user