277 lines
12 KiB
Python
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")
|