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")