automation and ai and some planning and fixes adn docs reorg
This commit is contained in:
@@ -87,13 +87,13 @@ class AICore:
|
||||
response_format: Optional[Dict] = None,
|
||||
api_key: Optional[str] = None,
|
||||
function_name: str = 'ai_request',
|
||||
function_id: Optional[str] = None,
|
||||
prompt_prefix: Optional[str] = None,
|
||||
tracker: Optional[ConsoleStepTracker] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Centralized AI request handler with console logging.
|
||||
All AI text generation requests go through this method.
|
||||
|
||||
|
||||
Args:
|
||||
prompt: Prompt text
|
||||
model: Model name (required - must be provided from IntegrationSettings)
|
||||
@@ -102,12 +102,13 @@ class AICore:
|
||||
response_format: Optional response format dict (for JSON mode)
|
||||
api_key: Optional API key override
|
||||
function_name: Function name for logging (e.g., 'cluster_keywords')
|
||||
prompt_prefix: Optional prefix to add before prompt (e.g., '##GP01-Clustering')
|
||||
tracker: Optional ConsoleStepTracker instance for logging
|
||||
|
||||
|
||||
Returns:
|
||||
Dict with 'content', 'input_tokens', 'output_tokens', 'total_tokens',
|
||||
'model', 'cost', 'error', 'api_id'
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: If model is not provided
|
||||
"""
|
||||
@@ -184,16 +185,16 @@ class AICore:
|
||||
else:
|
||||
tracker.ai_call("Using text response format")
|
||||
|
||||
# Step 4: Validate prompt length and add function_id
|
||||
# Step 4: Validate prompt length and add prompt_prefix
|
||||
prompt_length = len(prompt)
|
||||
tracker.ai_call(f"Prompt length: {prompt_length} characters")
|
||||
|
||||
# Add function_id to prompt if provided (for tracking)
|
||||
|
||||
# Add prompt_prefix to prompt if provided (for tracking)
|
||||
# Format: ##GP01-Clustering or ##CP01-Clustering
|
||||
final_prompt = prompt
|
||||
if function_id:
|
||||
function_id_prefix = f'function_id: "{function_id}"\n\n'
|
||||
final_prompt = function_id_prefix + prompt
|
||||
tracker.ai_call(f"Added function_id to prompt: {function_id}")
|
||||
if prompt_prefix:
|
||||
final_prompt = f'{prompt_prefix}\n\n{prompt}'
|
||||
tracker.ai_call(f"Added prompt prefix: {prompt_prefix}")
|
||||
|
||||
# Step 5: Build request payload
|
||||
url = 'https://api.openai.com/v1/chat/completions'
|
||||
|
||||
@@ -306,12 +306,13 @@ class AIEngine:
|
||||
|
||||
ai_core = AICore(account=self.account)
|
||||
function_name = fn.get_name()
|
||||
|
||||
# Generate function_id for tracking (ai-{function_name}-01)
|
||||
# Normalize underscores to hyphens to match frontend tracking IDs
|
||||
function_id_base = function_name.replace('_', '-')
|
||||
function_id = f"ai-{function_id_base}-01-desktop"
|
||||
|
||||
|
||||
# Generate prompt prefix for tracking (e.g., ##GP01-Clustering or ##CP01-Clustering)
|
||||
# This replaces function_id and indicates whether prompt is global or custom
|
||||
from igny8_core.ai.prompts import get_prompt_prefix_for_function
|
||||
prompt_prefix = get_prompt_prefix_for_function(function_name, account=self.account)
|
||||
logger.info(f"[AIEngine] Using prompt prefix: {prompt_prefix}")
|
||||
|
||||
# Get model config from settings (requires account)
|
||||
# This will raise ValueError if IntegrationSettings not configured
|
||||
try:
|
||||
@@ -349,7 +350,7 @@ class AIEngine:
|
||||
temperature=model_config.get('temperature'),
|
||||
response_format=model_config.get('response_format'),
|
||||
function_name=function_name,
|
||||
function_id=function_id # Pass function_id for tracking
|
||||
prompt_prefix=prompt_prefix # Pass prompt prefix for tracking (replaces function_id)
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = f"AI call failed: {str(e)}"
|
||||
|
||||
@@ -3,7 +3,7 @@ Prompt Registry - Centralized prompt management with override hierarchy
|
||||
Supports: task-level overrides → DB prompts → GlobalAIPrompt (REQUIRED)
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from django.db import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,10 +16,10 @@ class PromptRegistry:
|
||||
2. DB prompt for (account, function)
|
||||
3. GlobalAIPrompt (REQUIRED - no hardcoded fallbacks)
|
||||
"""
|
||||
|
||||
|
||||
# Removed ALL hardcoded prompts - GlobalAIPrompt is now the ONLY source of default prompts
|
||||
# To add/modify prompts, use Django admin: /admin/system/globalaiprompt/
|
||||
|
||||
|
||||
# Mapping from function names to prompt types
|
||||
FUNCTION_TO_PROMPT_TYPE = {
|
||||
'auto_cluster': 'clustering',
|
||||
@@ -35,7 +35,114 @@ class PromptRegistry:
|
||||
'generate_service_page': 'service_generation',
|
||||
'generate_taxonomy': 'taxonomy_generation',
|
||||
}
|
||||
|
||||
# Mapping of prompt types to their prefix numbers and display names
|
||||
# Format: {prompt_type: (number, display_name)}
|
||||
# GP = Global Prompt, CP = Custom Prompt
|
||||
PROMPT_PREFIX_MAP = {
|
||||
'clustering': ('01', 'Clustering'),
|
||||
'ideas': ('02', 'Ideas'),
|
||||
'content_generation': ('03', 'ContentGen'),
|
||||
'image_prompt_extraction': ('04', 'ImagePrompts'),
|
||||
'site_structure_generation': ('05', 'SiteStructure'),
|
||||
'optimize_content': ('06', 'OptimizeContent'),
|
||||
'product_generation': ('07', 'ProductGen'),
|
||||
'service_generation': ('08', 'ServiceGen'),
|
||||
'taxonomy_generation': ('09', 'TaxonomyGen'),
|
||||
'image_prompt_template': ('10', 'ImageTemplate'),
|
||||
'negative_prompt': ('11', 'NegativePrompt'),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_prompt_prefix(cls, prompt_type: str, is_custom: bool) -> str:
|
||||
"""
|
||||
Generate prompt prefix for tracking.
|
||||
|
||||
Args:
|
||||
prompt_type: The prompt type (e.g., 'clustering', 'ideas')
|
||||
is_custom: True if using custom/account-specific prompt, False if global
|
||||
|
||||
Returns:
|
||||
Prefix string like "##GP01-Clustering" or "##CP01-Clustering"
|
||||
"""
|
||||
prefix_info = cls.PROMPT_PREFIX_MAP.get(prompt_type, ('00', prompt_type.title()))
|
||||
number, display_name = prefix_info
|
||||
prefix_type = 'CP' if is_custom else 'GP'
|
||||
return f"##{prefix_type}{number}-{display_name}"
|
||||
|
||||
@classmethod
|
||||
def get_prompt_with_metadata(
|
||||
cls,
|
||||
function_name: str,
|
||||
account: Optional[Any] = None,
|
||||
task: Optional[Any] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> Tuple[str, bool, str]:
|
||||
"""
|
||||
Get prompt for a function with metadata about source.
|
||||
|
||||
Priority:
|
||||
1. task.prompt_override (if task provided and has override)
|
||||
2. DB prompt for (account, function) - marked as custom if is_customized=True
|
||||
3. GlobalAIPrompt (REQUIRED - no hardcoded fallbacks)
|
||||
|
||||
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:
|
||||
Tuple of (prompt_string, is_custom, prompt_type)
|
||||
- prompt_string: The rendered prompt
|
||||
- is_custom: True if using custom/account prompt, False if global
|
||||
- prompt_type: The prompt type identifier
|
||||
"""
|
||||
# Step 1: Get prompt type
|
||||
prompt_type = cls.FUNCTION_TO_PROMPT_TYPE.get(function_name, function_name)
|
||||
|
||||
# Step 2: Check task-level override (always considered custom)
|
||||
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 {}), True, prompt_type
|
||||
|
||||
# Step 3: Try DB prompt (account-specific)
|
||||
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
|
||||
)
|
||||
# Check if prompt is customized
|
||||
is_custom = db_prompt.is_customized
|
||||
logger.info(f"Using {'customized' if is_custom else 'default'} account prompt for {function_name} (account {account.id})")
|
||||
prompt = db_prompt.prompt_value
|
||||
return cls._render_prompt(prompt, context or {}), is_custom, prompt_type
|
||||
except Exception as e:
|
||||
logger.debug(f"No account-specific prompt found for {function_name}: {e}")
|
||||
|
||||
# Step 4: Try GlobalAIPrompt (platform-wide default) - REQUIRED
|
||||
try:
|
||||
from igny8_core.modules.system.global_settings_models import GlobalAIPrompt
|
||||
global_prompt = GlobalAIPrompt.objects.get(
|
||||
prompt_type=prompt_type,
|
||||
is_active=True
|
||||
)
|
||||
logger.info(f"Using global default prompt for {function_name} from GlobalAIPrompt")
|
||||
prompt = global_prompt.prompt_value
|
||||
return cls._render_prompt(prompt, context or {}), False, prompt_type
|
||||
except Exception as e:
|
||||
error_msg = (
|
||||
f"ERROR: Global prompt '{prompt_type}' not found for function '{function_name}'. "
|
||||
f"Please configure it in Django admin at: /admin/system/globalaiprompt/. "
|
||||
f"Error: {e}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
@classmethod
|
||||
def get_prompt(
|
||||
cls,
|
||||
@@ -46,63 +153,23 @@ class PromptRegistry:
|
||||
) -> 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. GlobalAIPrompt (REQUIRED - no hardcoded fallbacks)
|
||||
|
||||
|
||||
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 (account-specific)
|
||||
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 account-specific 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 account-specific prompt found for {function_name}: {e}")
|
||||
|
||||
# Step 4: Try GlobalAIPrompt (platform-wide default) - REQUIRED
|
||||
try:
|
||||
from igny8_core.modules.system.global_settings_models import GlobalAIPrompt
|
||||
global_prompt = GlobalAIPrompt.objects.get(
|
||||
prompt_type=prompt_type,
|
||||
is_active=True
|
||||
)
|
||||
logger.info(f"Using global default prompt for {function_name} from GlobalAIPrompt")
|
||||
prompt = global_prompt.prompt_value
|
||||
return cls._render_prompt(prompt, context or {})
|
||||
except Exception as e:
|
||||
error_msg = (
|
||||
f"ERROR: Global prompt '{prompt_type}' not found for function '{function_name}'. "
|
||||
f"Please configure it in Django admin at: /admin/system/globalaiprompt/. "
|
||||
f"Error: {e}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
prompt, _, _ = cls.get_prompt_with_metadata(function_name, account, task, context)
|
||||
return prompt
|
||||
|
||||
@classmethod
|
||||
def _render_prompt(cls, prompt_template: str, context: Dict[str, Any]) -> str:
|
||||
@@ -219,3 +286,61 @@ 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)
|
||||
|
||||
|
||||
def get_prompt_with_prefix(function_name: str, account=None, task=None, context=None) -> Tuple[str, str]:
|
||||
"""
|
||||
Get prompt with its tracking prefix.
|
||||
|
||||
Args:
|
||||
function_name: AI function name
|
||||
account: Account object (optional)
|
||||
task: Task object with optional prompt_override (optional)
|
||||
context: Additional context for prompt rendering (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt_string, prefix_string)
|
||||
- prompt_string: The rendered prompt
|
||||
- prefix_string: The tracking prefix (e.g., '##GP01-Clustering' or '##CP01-Clustering')
|
||||
"""
|
||||
prompt, is_custom, prompt_type = PromptRegistry.get_prompt_with_metadata(
|
||||
function_name, account=account, task=task, context=context
|
||||
)
|
||||
prefix = PromptRegistry.get_prompt_prefix(prompt_type, is_custom)
|
||||
return prompt, prefix
|
||||
|
||||
|
||||
def get_prompt_prefix_for_function(function_name: str, account=None, task=None) -> str:
|
||||
"""
|
||||
Get just the prefix for a function without fetching the full prompt.
|
||||
Useful when the prompt was already fetched elsewhere.
|
||||
|
||||
Args:
|
||||
function_name: AI function name
|
||||
account: Account object (optional)
|
||||
task: Task object with optional prompt_override (optional)
|
||||
|
||||
Returns:
|
||||
The tracking prefix (e.g., '##GP01-Clustering' or '##CP01-Clustering')
|
||||
"""
|
||||
prompt_type = PromptRegistry.FUNCTION_TO_PROMPT_TYPE.get(function_name, function_name)
|
||||
|
||||
# Check for task-level override (always custom)
|
||||
if task and hasattr(task, 'prompt_override') and task.prompt_override:
|
||||
return PromptRegistry.get_prompt_prefix(prompt_type, is_custom=True)
|
||||
|
||||
# Check for account-specific 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 PromptRegistry.get_prompt_prefix(prompt_type, is_custom=db_prompt.is_customized)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to global (not custom)
|
||||
return PromptRegistry.get_prompt_prefix(prompt_type, is_custom=False)
|
||||
|
||||
|
||||
@@ -119,10 +119,40 @@ class Tasks(SoftDeletableModel, SiteSectorBaseModel):
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None):
|
||||
"""
|
||||
Override soft_delete to cascade to related models.
|
||||
This ensures Images and ContentClusterMap are also deleted when a Task is deleted.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Soft-delete related Images (which are also SoftDeletable)
|
||||
related_images = self.images.filter(is_deleted=False)
|
||||
images_count = related_images.count()
|
||||
for image in related_images:
|
||||
image.soft_delete(user=user, reason=f"Parent task deleted: {reason or 'No reason'}")
|
||||
|
||||
# Hard-delete ContentClusterMap (not soft-deletable)
|
||||
cluster_maps_count = self.cluster_mappings.count()
|
||||
self.cluster_mappings.all().delete()
|
||||
|
||||
# Hard-delete ContentAttribute (not soft-deletable)
|
||||
attributes_count = self.attribute_mappings.count()
|
||||
self.attribute_mappings.all().delete()
|
||||
|
||||
logger.info(
|
||||
f"[Tasks.soft_delete] Task {self.id} '{self.title}' cascade delete: "
|
||||
f"{images_count} images, {cluster_maps_count} cluster maps, {attributes_count} attributes"
|
||||
)
|
||||
|
||||
# Call parent soft_delete
|
||||
super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
|
||||
|
||||
class ContentTaxonomyRelation(models.Model):
|
||||
"""Through model for Content-Taxonomy many-to-many relationship"""
|
||||
@@ -326,6 +356,61 @@ class Content(SoftDeletableModel, SiteSectorBaseModel):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error incrementing word usage for content {self.id}: {str(e)}")
|
||||
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None):
|
||||
"""
|
||||
Override soft_delete to cascade to related models.
|
||||
This ensures Images, ContentClusterMap, ContentAttribute are also deleted.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Soft-delete related Images (which are also SoftDeletable)
|
||||
related_images = self.images.filter(is_deleted=False)
|
||||
images_count = related_images.count()
|
||||
for image in related_images:
|
||||
image.soft_delete(user=user, reason=f"Parent content deleted: {reason or 'No reason'}")
|
||||
|
||||
# Hard-delete ContentClusterMap (not soft-deletable)
|
||||
cluster_maps_count = self.cluster_mappings.count()
|
||||
self.cluster_mappings.all().delete()
|
||||
|
||||
# Hard-delete ContentAttribute (not soft-deletable)
|
||||
attributes_count = self.attributes.count()
|
||||
self.attributes.all().delete()
|
||||
|
||||
# Hard-delete ContentTaxonomyRelation (through model for many-to-many)
|
||||
taxonomy_relations_count = ContentTaxonomyRelation.objects.filter(content=self).count()
|
||||
ContentTaxonomyRelation.objects.filter(content=self).delete()
|
||||
|
||||
logger.info(
|
||||
f"[Content.soft_delete] Content {self.id} '{self.title}' cascade delete: "
|
||||
f"{images_count} images, {cluster_maps_count} cluster maps, "
|
||||
f"{attributes_count} attributes, {taxonomy_relations_count} taxonomy relations"
|
||||
)
|
||||
|
||||
# Call parent soft_delete
|
||||
super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
|
||||
def hard_delete(self, using=None, keep_parents=False):
|
||||
"""
|
||||
Override hard_delete to cascade to related models.
|
||||
Django CASCADE should handle this, but we explicitly clean up for safety.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hard-delete related Images (including soft-deleted ones)
|
||||
images_count = Images.all_objects.filter(content=self).count()
|
||||
Images.all_objects.filter(content=self).delete()
|
||||
|
||||
logger.info(
|
||||
f"[Content.hard_delete] Content {self.id} '{self.title}' hard delete: "
|
||||
f"{images_count} images removed"
|
||||
)
|
||||
|
||||
# Call parent hard_delete (Django CASCADE will handle the rest)
|
||||
return super().hard_delete(using=using, keep_parents=keep_parents)
|
||||
|
||||
|
||||
class ContentTaxonomy(SiteSectorBaseModel):
|
||||
"""
|
||||
|
||||
@@ -121,14 +121,14 @@ class AIProcessor:
|
||||
temperature: float = 0.7,
|
||||
response_format: Optional[Dict] = None,
|
||||
api_key: Optional[str] = None,
|
||||
function_id: Optional[str] = None,
|
||||
prompt_prefix: Optional[str] = None,
|
||||
response_steps=None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Internal method to call OpenAI API.
|
||||
EXACT match to reference plugin's igny8_call_openai() function.
|
||||
Endpoint: https://api.openai.com/v1/chat/completions
|
||||
|
||||
|
||||
Returns:
|
||||
Dict with 'content', 'input_tokens', 'output_tokens', 'total_tokens', 'model', 'cost', 'error', 'api_id'
|
||||
"""
|
||||
@@ -159,12 +159,12 @@ class AIProcessor:
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Add function_id to prompt if provided (for tracking)
|
||||
# Add prompt_prefix to prompt if provided (for tracking)
|
||||
# Format: ##GP01-Clustering or ##CP01-Clustering
|
||||
final_prompt = prompt
|
||||
if function_id:
|
||||
function_id_prefix = f'function_id: "{function_id}"\n\n'
|
||||
final_prompt = function_id_prefix + prompt
|
||||
logger.info(f"Added function_id to prompt: {function_id}")
|
||||
if prompt_prefix:
|
||||
final_prompt = f'{prompt_prefix}\n\n{prompt}'
|
||||
logger.info(f"Added prompt prefix: {prompt_prefix}")
|
||||
|
||||
# EXACT request format from reference plugin (openai-api.php line 402-404)
|
||||
body_data = {
|
||||
@@ -463,13 +463,15 @@ class AIProcessor:
|
||||
Returns:
|
||||
Dict with 'content', 'tokens_used', 'model', 'cost', 'error'
|
||||
"""
|
||||
# Generate function_id for tracking (ai-generate-content-03 for AIProcessor path)
|
||||
function_id = "ai-generate-content-03"
|
||||
# Generate prompt prefix for tracking (e.g., ##GP03-ContentGen or ##CP03-ContentGen)
|
||||
from igny8_core.ai.prompts import get_prompt_prefix_for_function
|
||||
prompt_prefix = get_prompt_prefix_for_function('generate_content', account=self.account)
|
||||
|
||||
# Get response_format from settings for generate_content
|
||||
from igny8_core.ai.settings import get_model_config
|
||||
model_config = get_model_config('generate_content')
|
||||
model_config = get_model_config('generate_content', account=self.account)
|
||||
response_format = model_config.get('response_format')
|
||||
result = self._call_openai(prompt, model, max_tokens, temperature, response_format=response_format, function_id=function_id)
|
||||
result = self._call_openai(prompt, model, max_tokens, temperature, response_format=response_format, prompt_prefix=prompt_prefix)
|
||||
|
||||
return {
|
||||
'content': result.get('content', ''),
|
||||
|
||||
Reference in New Issue
Block a user