280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""
|
||
Prompt Registry - Centralized prompt management with override hierarchy
|
||
Supports: task-level overrides → DB prompts → default fallbacks
|
||
"""
|
||
import logging
|
||
from typing import Dict, Any, Optional
|
||
from django.db import models
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class PromptRegistry:
|
||
"""
|
||
Centralized prompt registry with hierarchical resolution:
|
||
1. Task-level prompt_override (if exists)
|
||
2. DB prompt for (account, function)
|
||
3. Default fallback from registry
|
||
"""
|
||
|
||
# Default prompts stored in registry
|
||
DEFAULT_PROMPTS = {
|
||
'clustering': """Analyze the following keywords and group them into topic clusters.
|
||
|
||
Each cluster should include:
|
||
- "name": A clear, descriptive topic name
|
||
- "description": A brief explanation of what the cluster covers
|
||
- "keywords": A list of related keywords that belong to this cluster
|
||
|
||
Format the output as a JSON object with a "clusters" array.
|
||
|
||
IMPORTANT: In the "keywords" array, you MUST use the EXACT keyword strings from the input list below. Do not modify, paraphrase, or create variations of the keywords. Only use the exact keywords as they appear in the input list.
|
||
|
||
Clustering rules:
|
||
- Group keywords based on strong semantic or topical relationships (intent, use-case, function, audience, etc.)
|
||
- Clusters should reflect how people actually search — problem ➝ solution, general ➝ specific, product ➝ benefit, etc.
|
||
- Avoid grouping keywords just because they share similar words — focus on meaning
|
||
- Include 3–10 keywords per cluster where appropriate
|
||
- Skip unrelated or outlier keywords that don't fit a clear theme
|
||
- CRITICAL: Only return keywords that exactly match the input keywords (case-insensitive matching is acceptable)
|
||
|
||
Keywords to process:
|
||
[IGNY8_KEYWORDS]""",
|
||
|
||
'ideas': """Generate SEO-optimized, high-quality content ideas and detailed outlines for each of the following keyword clusters.
|
||
|
||
Clusters to analyze:
|
||
[IGNY8_CLUSTERS]
|
||
|
||
Keywords in each cluster:
|
||
[IGNY8_CLUSTER_KEYWORDS]
|
||
|
||
Return your response as JSON with an "ideas" array.
|
||
For each cluster, generate 1-3 content ideas.
|
||
|
||
Each idea must include:
|
||
- "title": compelling blog/article title that naturally includes a primary keyword
|
||
- "description": detailed content outline with H2/H3 structure (as plain text or structured JSON)
|
||
- "content_type": the type of content (blog_post, article, guide, tutorial)
|
||
- "content_structure": the editorial structure (cluster_hub, landing_page, pillar_page, supporting_page)
|
||
- "estimated_word_count": estimated total word count (1500-2200 words)
|
||
- "target_keywords": comma-separated list of keywords that will be covered (or "covered_keywords")
|
||
- "cluster_name": name of the cluster this idea belongs to (REQUIRED)
|
||
- "cluster_id": ID of the cluster this idea belongs to (REQUIRED - use the exact cluster ID from the input)
|
||
|
||
IMPORTANT: You MUST include the exact "cluster_id" from the cluster data provided. Match the cluster name to find the correct cluster_id.
|
||
|
||
Return only valid JSON with an "ideas" array.""",
|
||
|
||
'content_generation': """You are an editorial content strategist. Generate a complete blog post/article based on the provided content idea.
|
||
|
||
CONTENT IDEA DETAILS:
|
||
[IGNY8_IDEA]
|
||
|
||
KEYWORD CLUSTER:
|
||
[IGNY8_CLUSTER]
|
||
|
||
ASSOCIATED KEYWORDS:
|
||
[IGNY8_KEYWORDS]
|
||
|
||
Generate well-structured, SEO-optimized content with:
|
||
- Engaging introduction
|
||
- 5-8 H2 sections with H3 subsections
|
||
- Natural keyword integration
|
||
- 1500-2000 words total
|
||
- Proper HTML formatting (h2, h3, p, ul, ol, table tags)
|
||
|
||
Return the content as plain text with HTML tags.""",
|
||
|
||
'image_prompt_extraction': """Extract image prompts from the following article content.
|
||
|
||
ARTICLE TITLE: {title}
|
||
|
||
ARTICLE CONTENT:
|
||
{content}
|
||
|
||
Extract image prompts for:
|
||
1. Featured Image: One main image that represents the article topic
|
||
2. In-Article Images: Up to {max_images} images that would be useful within the article content
|
||
|
||
Return a JSON object with this structure:
|
||
{{
|
||
"featured_prompt": "Detailed description of the featured image",
|
||
"in_article_prompts": [
|
||
"Description of first in-article image",
|
||
"Description of second in-article image",
|
||
...
|
||
]
|
||
}}
|
||
|
||
Make sure each prompt is detailed enough for image generation, describing the visual elements, style, mood, and composition.""",
|
||
|
||
'image_prompt_template': 'Create a high-quality {image_type} image to use as a featured photo for a blog post titled "{post_title}". The image should visually represent the theme, mood, and subject implied by the image prompt: {image_prompt}. Focus on a realistic, well-composed scene that naturally communicates the topic without text or logos. Use balanced lighting, pleasing composition, and photographic detail suitable for lifestyle or editorial web content. Avoid adding any visible or readable text, brand names, or illustrative effects. **And make sure image is not blurry.**',
|
||
|
||
'negative_prompt': 'text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title',
|
||
}
|
||
|
||
# Mapping from function names to prompt types
|
||
FUNCTION_TO_PROMPT_TYPE = {
|
||
'auto_cluster': 'clustering',
|
||
# REMOVED: generate_ideas function removed
|
||
# 'generate_ideas': 'ideas',
|
||
'generate_content': 'content_generation',
|
||
'generate_images': 'image_prompt_extraction',
|
||
'extract_image_prompts': 'image_prompt_extraction',
|
||
}
|
||
|
||
@classmethod
|
||
def get_prompt(
|
||
cls,
|
||
function_name: str,
|
||
account: Optional[Any] = None,
|
||
task: Optional[Any] = None,
|
||
context: Optional[Dict[str, Any]] = None
|
||
) -> str:
|
||
"""
|
||
Get prompt for a function with hierarchical resolution.
|
||
|
||
Priority:
|
||
1. task.prompt_override (if task provided and has override)
|
||
2. DB prompt for (account, function)
|
||
3. Default fallback from registry
|
||
|
||
Args:
|
||
function_name: AI function name (e.g., 'auto_cluster', 'generate_ideas')
|
||
account: Account object (optional)
|
||
task: Task object with optional prompt_override (optional)
|
||
context: Additional context for prompt rendering (optional)
|
||
|
||
Returns:
|
||
Prompt string ready for formatting
|
||
"""
|
||
# Step 1: Check task-level override
|
||
if task and hasattr(task, 'prompt_override') and task.prompt_override:
|
||
logger.info(f"Using task-level prompt override for {function_name}")
|
||
prompt = task.prompt_override
|
||
return cls._render_prompt(prompt, context or {})
|
||
|
||
# Step 2: Get prompt type
|
||
prompt_type = cls.FUNCTION_TO_PROMPT_TYPE.get(function_name, function_name)
|
||
|
||
# Step 3: Try DB prompt
|
||
if account:
|
||
try:
|
||
from igny8_core.modules.system.models import AIPrompt
|
||
db_prompt = AIPrompt.objects.get(
|
||
account=account,
|
||
prompt_type=prompt_type,
|
||
is_active=True
|
||
)
|
||
logger.info(f"Using DB prompt for {function_name} (account {account.id})")
|
||
prompt = db_prompt.prompt_value
|
||
return cls._render_prompt(prompt, context or {})
|
||
except Exception as e:
|
||
logger.debug(f"No DB prompt found for {function_name}: {e}")
|
||
|
||
# Step 4: Use default fallback
|
||
prompt = cls.DEFAULT_PROMPTS.get(prompt_type, '')
|
||
if not prompt:
|
||
logger.warning(f"No default prompt found for {prompt_type}, using empty string")
|
||
|
||
return cls._render_prompt(prompt, context or {})
|
||
|
||
@classmethod
|
||
def _render_prompt(cls, prompt_template: str, context: Dict[str, Any]) -> str:
|
||
"""
|
||
Render prompt template with context variables.
|
||
Supports both .format() style ({variable}) and placeholder replacement ([IGNY8_*]).
|
||
|
||
Args:
|
||
prompt_template: Prompt template string
|
||
context: Context variables for rendering
|
||
|
||
Returns:
|
||
Rendered prompt string
|
||
"""
|
||
if not context:
|
||
return prompt_template
|
||
|
||
rendered = prompt_template
|
||
|
||
# Step 1: Replace [IGNY8_*] placeholders first (always do this)
|
||
for key, value in context.items():
|
||
placeholder = f'[IGNY8_{key.upper()}]'
|
||
if placeholder in rendered:
|
||
rendered = rendered.replace(placeholder, str(value))
|
||
logger.debug(f"Replaced placeholder {placeholder} with {len(str(value))} characters")
|
||
|
||
# Step 2: Try .format() style for {variable} placeholders (if any remain)
|
||
# Normalize context keys - convert UPPER to lowercase for .format()
|
||
normalized_context = {}
|
||
for key, value in context.items():
|
||
# Try both original key and lowercase version
|
||
normalized_context[key] = value
|
||
normalized_context[key.lower()] = value
|
||
|
||
# Only try .format() if there are {variable} placeholders
|
||
if '{' in rendered and '}' in rendered:
|
||
try:
|
||
rendered = rendered.format(**normalized_context)
|
||
except (KeyError, ValueError) as e:
|
||
# If .format() fails, log warning but keep the [IGNY8_*] replacements
|
||
logger.warning(f"Failed to format prompt with .format(): {e}. Using [IGNY8_*] replacements only.")
|
||
|
||
return rendered
|
||
|
||
@classmethod
|
||
def get_image_prompt_template(cls, account: Optional[Any] = None) -> str:
|
||
"""
|
||
Get image prompt template.
|
||
Returns template string (not rendered) - caller should format with .format()
|
||
"""
|
||
prompt_type = 'image_prompt_template'
|
||
|
||
# Try DB prompt
|
||
if account:
|
||
try:
|
||
from igny8_core.modules.system.models import AIPrompt
|
||
db_prompt = AIPrompt.objects.get(
|
||
account=account,
|
||
prompt_type=prompt_type,
|
||
is_active=True
|
||
)
|
||
return db_prompt.prompt_value
|
||
except Exception:
|
||
pass
|
||
|
||
# Use default
|
||
return cls.DEFAULT_PROMPTS.get(prompt_type, '')
|
||
|
||
@classmethod
|
||
def get_negative_prompt(cls, account: Optional[Any] = None) -> str:
|
||
"""
|
||
Get negative prompt.
|
||
Returns template string (not rendered).
|
||
"""
|
||
prompt_type = 'negative_prompt'
|
||
|
||
# Try DB prompt
|
||
if account:
|
||
try:
|
||
from igny8_core.modules.system.models import AIPrompt
|
||
db_prompt = AIPrompt.objects.get(
|
||
account=account,
|
||
prompt_type=prompt_type,
|
||
is_active=True
|
||
)
|
||
return db_prompt.prompt_value
|
||
except Exception:
|
||
pass
|
||
|
||
# Use default
|
||
return cls.DEFAULT_PROMPTS.get(prompt_type, '')
|
||
|
||
|
||
# Convenience function for backward compatibility
|
||
def get_prompt(function_name: str, account=None, task=None, context=None) -> str:
|
||
"""Get prompt using registry"""
|
||
return PromptRegistry.get_prompt(function_name, account=account, task=task, context=context)
|
||
|