# -*- coding: utf-8 -*- from odoo import models, fields, api, _ from odoo.exceptions import UserError import logging import json import re import shutil from pathlib import Path _logger = logging.getLogger(__name__) class HelpdeskTicket(models.Model): _inherit = 'helpdesk.ticket' # Computed field to determine if team uses AI sale orders team_use_ai_sale_orders = fields.Boolean( string='Team Uses AI Sale Orders', compute='_compute_team_use_ai_sale_orders', readonly=True, ) ai_generated_products = fields.Text( string='AI Generated Products', readonly=True, help='Products suggested by AI based on ticket description', ) @api.depends('team_id') def _compute_team_use_ai_sale_orders(self): for ticket in self: ticket.team_use_ai_sale_orders = ticket.team_id._get_use_ai_sale_orders() if ticket.team_id else False def action_convert_to_sale_order(self): """Override to use AI if enabled""" self.ensure_one() # Check if AI sale orders are enabled for this team if self.team_use_ai_sale_orders: return self._ai_convert_to_sale_order() # Otherwise, use the standard method return super(HelpdeskTicket, self).action_convert_to_sale_order() def _ai_convert_to_sale_order(self): """Create a sale order using AI to suggest products based on ticket description""" self.ensure_one() values = self._get_sale_order_values() values["partner_id"] = self.partner_id.id # Ensure date_order is set and is a datetime object if 'date_order' not in values or not values['date_order']: from datetime import datetime values['date_order'] = datetime.now() # Fix empty string dates by converting them to None # This prevents PostgreSQL errors with empty string timestamps date_fields = ['date_order', 'commitment_date', 'validity_date'] for field in date_fields: if field in values and values[field] == '': values[field] = None # Create the sale order sale_order = self.env['sale.order'].create(values) # Link the ticket to the sale order sale_order.ticket_id = self.id # Post original email contents and document information to the chatter self._post_source_information_message(sale_order) return { 'type': 'ir.actions.act_window', 'name': _('Sale Order'), 'res_model': 'sale.order', 'res_id': sale_order.id, 'view_mode': 'form,list', 'context': self.env.context, } def _post_source_information_message(self, sale_order): """ Post a chatter message on the sale order with the original email contents and document information. This message will NOT be deleted when the sale order is confirmed, providing permanent context. Also attaches the original attachments from the helpdesk ticket to the sale order message. Args: sale_order: The sale order record """ # Get ticket data that was used for AI analysis ticket_data = self._prepare_ai_prompt_data() description = ticket_data.get('ticket_description', '') chatter_messages = ticket_data.get('ticket_messages', '') # Escape HTML special characters to prevent rendering issues import html description = html.escape(description) chatter_messages = html.escape(chatter_messages) # Create the message content message_body = f"""

🔍 Source Information from Helpdesk Ticket #{self.id}

This sale order was created from helpdesk ticket {self.name}

""" # Add original description if available if description: message_body += f"""

Original Ticket Description:

{description}
""" # Add chatter messages if available (limited to avoid huge messages) if chatter_messages: # Limit the size of chatter messages to avoid huge messages max_chars = 2000 if len(chatter_messages) > max_chars: chatter_messages = chatter_messages[:max_chars] + "... (truncated)" message_body += f"""

Relevant Chatter Messages:

{chatter_messages}
""" # Get the original attachments from the helpdesk ticket attachments = self.env['ir.attachment'].search([ ('res_id', '=', self.id), ('res_model', '=', self._name) ]) # Add attachment information to the message if attachments: message_body += f"""

Attachments: {len(attachments)} file(s) attached to this message

""" # Close the main div message_body += "
" try: _logger.debug(f"Attempting to post source information message for sale order {sale_order.id}") # Prepare attachment IDs to forward with the message attachment_ids = attachments.ids if attachments else [] _logger.debug(f"Forwarding {len(attachment_ids)} attachments from ticket {self.id} to sale order {sale_order.id}") # Post the message with attachments result = sale_order.message_post( body=message_body, subject="Source Information", body_is_html=True, attachment_ids=attachment_ids ) _logger.debug(f"Message post result: {result}") _logger.debug(f"Successfully posted source information message for sale order {sale_order.id}") except Exception as e: _logger.error(f"Error posting source information message: {e}") def _get_sale_order_values(self) -> dict: """ Generate sales order values using AI to analyze ticket content, chatter messages, and attachments. The AI will identify products from the content and match them to Odoo products. Returns: dict: Values for creating a sales order including order lines """ self.ensure_one() _logger.debug(f"Generating AI sales order values for ticket {self.id}") # Get the ticket data ticket_data = self._prepare_ai_prompt_data() description = ticket_data.get('ticket_description', '') chatter_messages = ticket_data.get('ticket_messages', '') attachments_info = ticket_data.get('attachments_info', '') attachment_contents = ticket_data.get('attachment_contents', '') # Log the content being analyzed _logger.debug(f"AI Analysis - Content lengths: Description={len(description)}, Chatter={len(chatter_messages)}, Attachments={len(attachment_contents)}") # Get the OpenWebUI provider from company settings company = self.env.company provider = company.openwebui_provider_id if not provider: _logger.error("No OpenWebUI provider configured for company") return {"order_line": []} # Get the OpenWebUI client from the provider ai_client = provider.get_client() # Register the product finding methods as tools registry = ai_client.tool_registry registry.register( self._ai_find_product_id_by_name, non_ai_params=["self"], description="Find a product by its name and return its ID. Input: name (string) - The name of the product to find. Returns the product ID if found, or null if not found." ) registry.register( self._ai_find_product_id_by_code, non_ai_params=["self"], description="Find a product by its code/reference and return its ID. Input: code (string) - The code/reference of the product to find. Returns the product ID if found, or null if not found." ) # Process PDF attachments for analysis attachments_list = [] attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)]) for attachment in attachments: if attachment.mimetype == 'application/pdf': try: temp_path = f"/tmp/{attachment.name}" shutil.copy(attachment._full_path(attachment.store_fname), temp_path) attachments_list.append(Path(temp_path)) except Exception as e: _logger.error(f"Error processing attachment {attachment.name}: {e}") # Create the prompt for the AI prompt = f"""IMPORTANT: YOU ARE NOT A CONVERSATIONAL ASSISTANT. YOU ARE A DATA EXTRACTION SYSTEM. Your ONLY function is to analyze the provided content and return a structured JSON object so that later it can be used to create a sales order. DO NOT introduce yourself, explain what you can or cannot do, or engage in conversation. ONLY RETURN THE REQUESTED JSON DATA STRUCTURE. TASK: Extract product information and sales order details from the following content: Customer Request: {description} Chatter Messages (IMPORTANT - CAREFULLY ANALYZE THESE FOR PRODUCT INFORMATION): {chatter_messages} Attachments Information: {attachments_info} Attachment Contents (CRITICALLY IMPORTANT - ANALYZE PDF CONTENTS FOR ALL PRODUCT DETAILS): {attachment_contents} WORKFLOW - FOLLOW THESE STEPS EXACTLY: 1. Identify ONLY ACTUAL PRODUCTS mentioned in the content (product names, codes, references) from BOTH chatter messages AND PDF attachments 2. For EACH product identified (from BOTH sources): a. If you find a product code/reference, call _ai_find_product_id_by_code with that code b. If you only have a product name, call _ai_find_product_id_by_name with that name c. If the product is not found in the database, use the display_type: 'line_note' format with this EXACT format: "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)" 3. Extract order details (client reference, dates, notes) 4. Construct the JSON response using the product IDs you obtained from tool calls RESPONSE FORMAT: Your response MUST ONLY be a valid JSON object with the following structure: {{ "client_order_ref": "Customer PO number if mentioned", "date_order": "YYYY-MM-DD format if a specific order date is mentioned", "commitment_date": "YYYY-MM-DD format if a delivery date is mentioned", "note": "Any special instructions or notes for the order", "order_line": [ [0, 0, {{ "product_id": NUMERIC_ID_FROM_TOOL_CALL, // Must be an actual ID returned from a tool call "product_uom_qty": QUANTITY, "price_unit": PRICE }}], [0, 0, {{ "display_type": "line_note", "name": "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)", "product_uom_qty": 0.0 }}] ] }} CRITICAL RULES FOR IDENTIFYING PRODUCTS: 1. A product MUST have AT LEAST ONE of the following to be considered a valid product: - A quantity AND a price - A product code/reference - A clearly identifiable product name with quantity 2. DO NOT identify random descriptions, paragraphs, or sections of text as products 3. Be aware that some items may have very long descriptions - this doesn't make them separate products 4. If a product contains subproducts (e.g., a kit or bundle), only identify the MAIN product, not each subproduct 5. If uncertain whether something is a product or just a description, look for quantity and price indicators 6. VERY IMPORTANT: Read product descriptions carefully as they often contain critical information to help identify the correct product 7. For unfound products, capture as much of the description as possible - this helps users identify what the product actually is 8. IMPORTANT: ANY item in a PDF that looks like it could be a product MUST be checked using the tools 9. When in doubt about whether something is a product, ALWAYS check it using the tools CRITICAL RULES FOR RESPONSE: 1. You MUST use the provided tools for EVERY valid product mentioned in BOTH chatter messages AND PDF attachments 2. product_id MUST be a numeric ID returned by a tool call, NEVER make up IDs 3. For products not found in the database, use the display_type: 'line_note' format with this EXACT format: "Unmatched product: Product name (qty: QUANTITY) (ref: REFERENCE if available)" 4. Include as much detail as possible for unmatched products including quantity, reference, and description if available 5. Return ONLY valid JSON with no text before or after 6. DO NOT explain what you're doing or respond conversationally 7. DO NOT say you can't create a sales order - your job is ONLY to return the JSON data IMPORTANT: You must invoke the tools directly using function calling, not just output text that looks like a tool call. Use the provided tools via function calling for _ai_find_product_id_by_code and _ai_find_product_id_by_name. """ # Call the AI with tools try: _logger.debug("Sending request to AI for sales order generation") # First, get the AI's analysis with tool calls to find product IDs response = ai_client.chat_with_tools( messages=[ { "role": "system", "content": "You are a data extraction system with access to tools for finding product IDs in an Odoo database. YOU MUST USE THE TOOLS PROVIDED TO ACCURATELY MATCH PRODUCTS PROVIDED TO THE DATABASE. Your ONLY job is to extract product information and return a structured JSON object. DO NOT engage in conversation or explain what you can or cannot do. ONLY return the requested JSON data structure. CRITICALLY IMPORTANT: You MUST identify ONLY ACTUAL PRODUCTS mentioned in BOTH chatter messages AND PDF attachments. A valid product MUST have a quantity AND either a price or product code. DO NOT identify random descriptions or paragraphs as products. VERY IMPORTANT: Read product descriptions carefully as they often contain critical information to help identify the correct product. For ANY product that cannot be found in the database, you MUST include it as a line note with display_type: 'line_note' in your response, and include as much description as possible." }, { "role": "user", "content": prompt } ], tools=["_ai_find_product_id_by_name", "_ai_find_product_id_by_code"], tool_params={}, max_tool_calls=25, files=attachments_list ) _logger.debug(f"Received AI response for ticket {self.id}") except Exception as e: _logger.error(f"Error in AI request: {e}") import traceback _logger.error(f"Traceback: {traceback.format_exc()}") return { "order_line": [], "note": f"AI Error: {str(e)}" } # Process the AI response _logger.debug(f"AI response type: {type(response)}") # Add detailed logging of the response content try: _logger.debug(f"AI response content: {json.dumps(response, indent=2)[:1000]}...") except: _logger.debug("Could not serialize AI response for logging") # If it's a dictionary, use it directly if isinstance(response, dict): _logger.debug("AI returned dictionary response, using directly") return response # Handle string responses (extract JSON if possible) if isinstance(response, str): _logger.debug("AI returned text response, attempting to extract JSON") try: # Look for JSON pattern in the text json_pattern = r'```(?:json)?\s*({[\s\S]*?})\s*```' json_matches = re.findall(json_pattern, response) if json_matches: response_data = json.loads(json_matches[0]) return response_data elif response.strip().startswith('{') and response.strip().endswith('}'): response_data = json.loads(response.strip()) return response_data else: _logger.error("Could not extract JSON from text response") return { "order_line": [], "note": f"AI returned invalid format: {response[:200]}..." } except Exception as parse_error: _logger.error(f"Failed to extract JSON from text response: {parse_error}") return { "order_line": [], "note": f"AI parsing error: {str(parse_error)}" } # If we get here, the response is in an unexpected format _logger.error(f"Unexpected response format: {type(response)}") return { "order_line": [], "note": f"AI returned unexpected format: {type(response)}" } def _prepare_ai_prompt_data(self): """ Extract and prepare all relevant data from the helpdesk ticket for AI analysis. This includes ticket description, chatter messages, and attachment contents. Returns: dict: Dictionary containing ticket data for AI analysis """ self.ensure_one() _logger.debug(f"Preparing AI prompt data for ticket {self.id}") result = { 'ticket_description': '', 'ticket_messages': '', 'attachments_info': '', 'attachment_contents': '' } # Get ticket description if self.description: result['ticket_description'] = self.description # Get chatter messages messages = [] if self.message_ids: for message in self.message_ids: if message.body and not message.is_internal: # Skip system messages and focus on actual conversation if not message.author_id or message.author_id.name != 'OdooBot': # Format: [Author] on [Date]: [Message] author = message.author_id.name if message.author_id else 'System' date = message.date.strftime('%Y-%m-%d %H:%M') if message.date else '' # Clean HTML from message body body = re.sub(r'<[^>]+>', ' ', message.body) messages.append(f"[{author}] on {date}: {body}") result['ticket_messages'] = '\n\n'.join(messages) # Get attachments attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)]) attachment_infos = [] attachment_contents = [] for attachment in attachments: # Add attachment metadata attachment_infos.append(f"File: {attachment.name} ({attachment.mimetype}, {attachment.file_size} bytes)") # Add PDF content reference if attachment.mimetype == 'application/pdf': attachment_contents.append(f"IMPORTANT PDF CONTENT: {attachment.name} - The AI must analyze this PDF for ALL product mentions and include ANY products found as either matched products or unmatched line notes") result['attachments_info'] = '\n'.join(attachment_infos) result['attachment_contents'] = '\n\n'.join(attachment_contents) return result def _prepare_order_line_values(self, product, quantity, description=""): """Prepare values for creating a sale order line""" return { 'product_id': product.id, 'product_uom_qty': quantity, 'name': description or product.name, } def _ai_find_product_id_by_name(self, product_name: str) -> int | None: """Find a product by name Args: product_name: The name of the product to find Returns: The ID of the product if found, or None if not found """ return self.env['product.product'].search([ ('name', 'ilike', product_name), ('sale_ok', '=', True) ], limit=1).id def _ai_find_product_id_by_code(self, product_reference: str) -> int | None: """Find a product by code Args: product_reference: The code of the product to find Returns: The ID of the product if found, or None if not found """ return self.env['product.product'].search([ ('default_code', 'ilike', product_reference), ('sale_ok', '=', True) ], limit=1).id