Files
seo/otoolkit_seo_content/wizards/generate_content_wizard.py

277 lines
12 KiB
Python

from odoo import api, fields, models, _
from odoo.exceptions import UserError
class GenerateContentWizard(models.TransientModel):
_name = 'otk.seo.content.generate.wizard'
_description = 'Generate SEO Content Wizard'
# === Wizard State ===
state = fields.Selection([
('step_source', 'Content & Source'),
('step_settings', 'Settings'),
('step_images', 'Images'),
('step_review', 'Review & Generate'),
], default='step_source', string='Step')
# === Step 1: Content Type & Source ===
content_type = fields.Selection([
('blog_post', 'Blog Post'),
('product_desc', 'Product Description'),
('category_desc', 'Category Description'),
('landing_page', 'Landing Page Content'),
], string='Content Type', required=True, default='blog_post',
help='Select the type of content you want to generate. Each type has optimized formatting and structure.')
source_type = fields.Selection([
('keywords', 'From Keywords/Topic'),
('product', 'From Product'),
('existing', 'Optimize Existing Content'),
], string='Source', default='keywords',
help='Choose how to provide input for content generation. Keywords/Topic: enter target keywords and a brief; From Product: use product data; Optimize Existing: improve existing text.')
source_keywords = fields.Char('Target Keywords',
help='Enter comma-separated keywords you want the content to rank for (e.g., "best coffee maker, home brewing, espresso machine").')
source_topic = fields.Text('Topic/Brief',
help='Describe what the content should cover. The more detail you provide, the better the AI can match your expectations.')
source_product_id = fields.Many2one('product.template', 'Source Product',
help='Select a product to generate content about. The AI will use the product name, description, and attributes.')
source_text = fields.Text('Existing Content',
help='Paste your existing content here. The AI will optimize it for SEO while maintaining the core message.')
# === Step 2: Settings ===
template_id = fields.Many2one('otk.seo.template', 'Content Template',
domain="[('content_type', '=', content_type)]",
help='Optional: Choose a template to apply predefined prompts, tone, and formatting. Leave empty for default behavior.')
brand_voice_id = fields.Many2one('otk.seo.brand.voice', 'Brand Voice',
default=lambda self: self._default_brand_voice(),
help='Select a brand voice to ensure the AI generates content matching your brand tone, style, and vocabulary.')
tone = fields.Selection([
('professional', 'Professional'),
('casual', 'Casual'),
('technical', 'Technical'),
('persuasive', 'Persuasive'),
('conversational', 'Conversational'),
], string='Tone', default='professional',
help='The writing style for your content. Professional for B2B; Casual for lifestyle; Technical for documentation; Persuasive for sales; Conversational for engaging blog posts.')
target_word_count = fields.Selection([
('short', 'Short (300-500 words)'),
('medium', 'Medium (500-1000 words)'),
('long', 'Long (1000-2000 words)'),
], string='Target Length', default='medium',
help='Approximate word count for the generated content. Longer content generally performs better for SEO but costs more tokens.')
language_id = fields.Many2one('res.lang', 'Language',
default=lambda self: self._default_language(),
help='The language in which the content will be written. Defaults to your Odoo user language.')
# === References ===
reference_ids = fields.Many2many('otk.seo.reference',
'otk_seo_wizard_reference_rel', 'wizard_id', 'reference_id',
string='References',
domain="['|', ('content_type', '=', content_type), ('content_type', '=', 'all')]",
help='Select reference materials to guide the AI. Max 5 references. These provide examples and inspiration for the generated content.')
# === Step 3: Image Settings ===
include_images = fields.Boolean('Generate Images', default=True,
help='Enable to have AI generate images for your content. Images will be created asynchronously.')
image_count = fields.Integer('Number of Images', default=1,
help='How many images to generate. The first image is typically used as the cover image.')
image_style = fields.Selection([
('photorealistic', 'Photorealistic'),
('illustration', 'Illustration'),
('digital_art', 'Digital Art'),
('watercolor', 'Watercolor'),
('sketch', 'Sketch'),
('3d_render', '3D Render'),
], string='Image Style', default='photorealistic',
help='Visual style for generated images.')
# === Advanced Options (visible in step 3) ===
include_toc = fields.Boolean('Include Table of Contents',
help='Add a table of contents at the beginning of the content. Recommended for longer articles.')
include_faq = fields.Boolean('Include FAQ Section',
help='Add a FAQ section at the end. Great for SEO as it can appear in featured snippets.')
custom_instructions = fields.Text('Custom Instructions',
help='Any additional instructions for the AI (e.g., "Focus on sustainability", "Include pricing comparisons", "Avoid technical jargon").')
# === Step 4: Target ===
blog_id = fields.Many2one('blog.blog', 'Target Blog',
help='Select which blog to publish to. Leave empty to choose later or use the default blog from settings.')
# === Cost Estimation ===
estimated_cost = fields.Integer('Estimated Tokens', compute='_compute_estimated_cost')
cost_breakdown = fields.Text('Cost Breakdown', compute='_compute_estimated_cost')
def _default_language(self):
lang_code = self.env.lang or 'en_US'
lang = self.env['res.lang']._lang_get(lang_code)
return lang.id if lang else False
def _default_brand_voice(self):
"""Return the default brand voice if one exists."""
return self.env['otk.seo.brand.voice'].search([('is_default', '=', True)], limit=1)
@api.depends('target_word_count', 'include_images', 'image_count')
def _compute_estimated_cost(self):
# Fetch dynamic pricing from API (cached 1h in ir.config_parameter)
pricing = self.env['otk.seo.content'].get_pricing_estimates()
text_map = pricing.get('text', {'short': 150, 'medium': 300, 'long': 600})
tokens_per_image = pricing.get('image', 150)
for record in self:
text_tokens = text_map.get(record.target_word_count or 'medium', text_map.get('medium', 300))
image_tokens = (record.image_count or 0) * tokens_per_image if record.include_images else 0
record.estimated_cost = text_tokens + image_tokens
lines = [f"Text generation: ~{text_tokens} tokens"]
if image_tokens:
lines.append(f"Image generation ({record.image_count} images): ~{image_tokens} tokens")
lines.append(f"Total estimate: ~{text_tokens + image_tokens} tokens")
record.cost_breakdown = '\n'.join(lines)
@api.onchange('template_id')
def _onchange_template_id(self):
if self.template_id:
self.tone = self.template_id.default_tone
self.target_word_count = self.template_id.default_word_count
self.include_images = self.template_id.include_images
self.image_count = self.template_id.default_image_count
self.image_style = self.template_id.default_image_style
self.include_toc = self.template_id.include_toc
self.include_faq = self.template_id.include_faq
# Inherit references from template
if self.template_id.reference_ids:
self.reference_ids = [(6, 0, self.template_id.reference_ids.ids)]
@api.onchange('source_type')
def _onchange_source_type(self):
if self.source_type == 'product':
self.content_type = 'product_desc'
elif self.source_type == 'existing':
self.content_type = 'blog_post'
@api.onchange('source_product_id')
def _onchange_source_product_id(self):
if self.source_product_id:
self.source_keywords = self.source_product_id.name
self.source_topic = self._prepare_product_brief()
def _prepare_product_brief(self):
"""Prepare product information for AI."""
if not self.source_product_id:
return ''
product = self.source_product_id
brief = f"Product: {product.name}\n"
if product.description:
brief += f"Description: {product.description}\n"
if product.list_price:
brief += f"Price: {product.list_price}\n"
if product.categ_id:
brief += f"Category: {product.categ_id.complete_name}\n"
return brief
# === Step Navigation ===
def _validate_step_source(self):
"""Validate Step 1 inputs by source type."""
self.ensure_one()
if self.source_type == 'keywords' and not self.source_keywords and not self.source_topic:
raise UserError(_("Please provide keywords or a topic description."))
if self.source_type == 'product' and not self.source_product_id:
raise UserError(_("Please select a product."))
if self.source_type == 'existing' and not self.source_text:
raise UserError(_("Please provide existing content to optimize."))
def action_next_step(self):
"""Validate current step and advance to next."""
self.ensure_one()
steps = ['step_source', 'step_settings', 'step_images', 'step_review']
current_idx = steps.index(self.state)
# Validate current step
if self.state == 'step_source':
self._validate_step_source()
if current_idx < len(steps) - 1:
self.state = steps[current_idx + 1]
return self._reopen_wizard()
def action_prev_step(self):
"""Go back one step."""
self.ensure_one()
steps = ['step_source', 'step_settings', 'step_images', 'step_review']
current_idx = steps.index(self.state)
if current_idx > 0:
self.state = steps[current_idx - 1]
return self._reopen_wizard()
def _reopen_wizard(self):
"""Reopen the wizard at its current step."""
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_generate(self):
"""Create content record and start generation (final step action)."""
self.ensure_one()
# Validate inputs
self._validate_step_source()
# Create content record
content_vals = {
'name': self._generate_initial_name(),
'content_type': self.content_type,
'source_keywords': self.source_keywords,
'source_topic': self.source_topic,
'source_product_id': self.source_product_id.id if self.source_product_id else False,
'source_text': self.source_text,
'template_id': self.template_id.id if self.template_id else False,
'brand_voice_id': self.brand_voice_id.id if self.brand_voice_id else False,
'tone': self.tone,
'target_word_count': self.target_word_count,
'language_id': self.language_id.id if self.language_id else False,
'requested_image_count': self.image_count if self.include_images else 0,
'image_style': self.image_style if self.include_images else False,
'blog_id': self.blog_id.id if self.blog_id else False,
'reference_ids': [(6, 0, self.reference_ids.ids)] if self.reference_ids else False,
'state': 'draft',
}
content = self.env['otk.seo.content'].create(content_vals)
# Start generation
content.action_generate()
# Return action to view the content
return {
'type': 'ir.actions.act_window',
'res_model': 'otk.seo.content',
'res_id': content.id,
'view_mode': 'form',
'target': 'current',
}
def _generate_initial_name(self):
"""Generate an initial name for the content."""
if self.source_keywords:
return self.source_keywords[:100]
elif self.source_topic:
return self.source_topic[:100]
elif self.source_product_id:
return f"Content for {self.source_product_id.name}"
else:
return _("New SEO Content")