bemade-addons/helpdesk_sale_order_ai/models/helpdesk_ticket.py
mathis ed9dda9bea [ADD] openwebui_connector, helpdesk_sale_order_ai: AI-powered sales order generation from helpdesk tickets
This commit introduces AI integration for helpdesk tickets to automatically generate sales orders:

- openwebui_connector: New module providing integration with OpenWebUI AI service
  * Configurable API connection (key, base URL, model)
  * AI prompt template system for reusable prompts
  * Uses Claude 3 Sonnet model by default

- helpdesk_sale_order_ai: Extends helpdesk_sale_order with AI capabilities
  * AI-powered analysis of ticket content to suggest products
  * Smart product quantity parsing from various formats
  * Dedicated UI tab for AI suggestions in helpdesk tickets
  * Auto-creation of sales orders with matched products

The integration streamlines the process of converting customer support requests into sales opportunities.
2025-07-15 15:18:01 -04:00

692 lines
33 KiB
Python

# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
import json
import re
_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:
if ticket.team_id:
ticket.team_use_ai_sale_orders = ticket.team_id._get_use_ai_sale_orders()
else:
ticket.team_use_ai_sale_orders = 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()
_logger.info("Starting AI conversion to sale order for ticket %s", self.id)
# Always generate fresh AI suggestions
_logger.info("Generating fresh AI suggestions for ticket %s", self.id)
result = self._generate_ai_product_suggestions()
_logger.info("AI suggestion generation result for ticket %s: %s", self.id, result)
# Get base values for sale order (partner, pricelist, etc.)
partner_id = self.partner_id.id
partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice']
partner_shipping_id = self.partner_id.address_get(['delivery'])['delivery']
# Parse AI suggestions to get order lines and sale order fields
ai_data = {'order_lines': [], 'sale_order_fields': {}}
if self.ai_generated_products:
_logger.info("AI suggestions found for ticket %s, parsing them now: %s", self.id, self.ai_generated_products[:200])
ai_data = self._parse_ai_product_suggestions()
_logger.info("Parsed AI data: %s", ai_data)
# Prepare sale order values
so_values = {
'partner_id': partner_id,
'partner_invoice_id': partner_invoice_id,
'partner_shipping_id': partner_shipping_id,
'ticket_id': self.id,
'origin': self.name,
'note': self.description,
}
# Add AI-extracted fields to sale order values if available
if ai_data.get('sale_order_fields'):
so_fields = ai_data['sale_order_fields']
# Client order reference (PO number)
if so_fields.get('client_order_ref'):
so_values['client_order_ref'] = so_fields['client_order_ref']
_logger.info(f"Setting client_order_ref to: {so_fields['client_order_ref']}")
# Order date
if so_fields.get('date_order'):
try:
# Validate date format
from datetime import datetime
date_order = datetime.strptime(so_fields['date_order'], '%Y-%m-%d')
so_values['date_order'] = date_order
_logger.info(f"Setting date_order to: {so_fields['date_order']}")
except (ValueError, TypeError) as e:
_logger.warning(f"Invalid date_order format: {so_fields['date_order']}, error: {e}")
# Commitment date (delivery date)
if so_fields.get('commitment_date'):
try:
# Validate date format
from datetime import datetime
commitment_date = datetime.strptime(so_fields['commitment_date'], '%Y-%m-%d')
so_values['commitment_date'] = commitment_date
_logger.info(f"Setting commitment_date to: {so_fields['commitment_date']}")
except (ValueError, TypeError) as e:
_logger.warning(f"Invalid commitment_date format: {so_fields['commitment_date']}, error: {e}")
# Note (special instructions)
if so_fields.get('note'):
# Append to existing note if any
existing_note = so_values.get('note', '')
if existing_note:
so_values['note'] = f"{existing_note}\n\n{so_fields['note']}"
else:
so_values['note'] = so_fields['note']
_logger.info(f"Setting note to: {so_values['note'][:100]}...")
# Payment terms
if so_fields.get('payment_term_id'):
# Try to find matching payment term
payment_term_name = so_fields['payment_term_id']
payment_term = self.env['account.payment.term'].search(
['|', ('name', '=', payment_term_name), ('name', 'ilike', payment_term_name)], limit=1)
if payment_term:
so_values['payment_term_id'] = payment_term.id
_logger.info(f"Setting payment_term_id to: {payment_term.name} (ID: {payment_term.id})")
else:
_logger.warning(f"Payment term not found: {payment_term_name}")
# Create the sale order
sale_order = self.env['sale.order'].create(so_values)
_logger.info(f"Created sale order with ID {sale_order.id}")
# Add order lines to the sale order
order_lines = ai_data.get('order_lines', [])
_logger.info("Adding %d order lines to sale order %s", len(order_lines), sale_order.id)
for line in order_lines:
# Each line is a tuple (0, 0, values_dict)
# Extract the values dict
line_values = line[2]
_logger.info("Creating order line with values: %s", line_values)
# Create a new order line that will trigger price computation
# Include the price_unit from the parsed data if available
initial_values = {
'order_id': sale_order.id,
'product_id': line_values.get('product_id'),
'product_uom_qty': line_values.get('product_uom_qty'),
'name': line_values.get('name'),
}
# Get the price that was calculated in _create_product_order_line
calculated_price = line_values.get('price_unit')
if calculated_price is not None:
_logger.info(f"Using pre-calculated price: {calculated_price} for product ID: {line_values.get('product_id')}")
initial_values['price_unit'] = calculated_price
order_line = self.env['sale.order.line'].new(initial_values)
# Trigger standard Odoo onchange to compute prices
try:
# This is the main onchange that should set the price based on product and pricelist
order_line._onchange_product_id()
# Log the computed price for debugging
_logger.info(f"Standard Odoo price computation: {order_line.price_unit} for product {order_line.product_id.name}")
# If price is still 0 and product has a list price, use that as fallback
if order_line.price_unit == 0 and order_line.product_id.list_price > 0:
order_line.price_unit = order_line.product_id.list_price
_logger.info(f"Price was 0, using product list price: {order_line.price_unit}")
except Exception as e:
_logger.error(f"Error in standard price computation: {str(e)}")
# Continue with creation even if price computation fails
# Create a clean dict with only the necessary values
order_line_values = {
'order_id': sale_order.id,
'product_id': order_line.product_id.id,
'product_uom_qty': order_line.product_uom_qty,
'name': order_line.name,
'price_unit': order_line.price_unit,
}
# Add product_uom if it exists
if order_line.product_uom:
order_line_values['product_uom'] = order_line.product_uom.id
# Create the actual order line with computed prices
self.env['sale.order.line'].create(order_line_values)
# Link the sale order to the ticket
self.write({
'sale_order_id': sale_order.id,
})
# Return the action to view the created 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 _generate_ai_product_suggestions(self):
"""Use AI to generate product suggestions based on ticket description, chatter messages and attachments"""
self.ensure_one()
_logger.info("Generating AI product suggestions for ticket %s", self.id)
# Get the ticket description
description = self.description or ""
# If description is empty, try to use the name
if not description.strip():
description = self.name or ""
# Get chatter messages
chatter_messages = ""
if self.message_ids:
for message in self.message_ids:
if message.body and not message.is_internal:
# Extract text from HTML
body_text = re.sub(r'<[^>]+>', ' ', message.body)
chatter_messages += f"Message from {message.author_id.name or 'Unknown'}: {body_text}\n\n"
# Get attachments
attachments_info = ""
attachment_contents = ""
if self.message_ids:
for message in self.message_ids:
if message.attachment_ids:
for attachment in message.attachment_ids:
attachments_info += f"Attachment: {attachment.name} ({attachment.mimetype})\n"
# Extract text from PDF attachments
if attachment.mimetype == 'application/pdf' and attachment.datas:
try:
import base64
import io
# Try to use PyPDF2 if available
try:
from PyPDF2 import PdfReader
pdf_data = base64.b64decode(attachment.datas)
pdf_file = io.BytesIO(pdf_data)
pdf_reader = PdfReader(pdf_file)
pdf_text = ""
for page_num in range(len(pdf_reader.pages)): # Process all pages
page = pdf_reader.pages[page_num]
pdf_text += page.extract_text() + "\n"
attachment_contents += f"Content from {attachment.name}:\n{pdf_text}\n\n" # Include full text
except ImportError:
_logger.warning("PyPDF2 not available, skipping PDF text extraction")
except Exception as e:
_logger.error(f"Error extracting text from PDF: {str(e)}")
# If everything is empty, show error
if not description.strip() and not chatter_messages.strip() and not attachment_contents.strip():
_logger.error("No content available for AI analysis")
return False
# Create the prompt for the AI
prompt = f"""You are an expert sales assistant for a pneumatic automation company.
Your task is to analyze the customer request and suggest appropriate products or services.
Customer Request:
{description}
Chatter Messages:
{chatter_messages}
Attachments Information:
{attachments_info}
Attachment Contents:
{attachment_contents}
Based on this information, please perform two tasks:
1. Map the following Odoo sale order fields from the information provided:
- client_order_ref: Customer's reference/PO number
- date_order: Order date (in YYYY-MM-DD format)
- commitment_date: Delivery date (in YYYY-MM-DD format)
- note: Any special instructions or notes
- payment_term_id: Payment terms (e.g., "Net 30", "2% 10 Net 30")
2. Suggest products or services that would meet the customer's needs.
IMPORTANT: Your response MUST be in valid JSON format as shown below. Do not include any explanatory text outside the JSON structure.
```json
{{
"sale_order_fields": {{
"client_order_ref": "Customer PO number",
"date_order": "YYYY-MM-DD",
"commitment_date": "YYYY-MM-DD",
"note": "Special instructions",
"payment_term_id": "Payment terms"
}},
"products": [
{{ "name": "Product Name", "quantity": 2, "description": "Product description" }},
{{ "name": "Another Product", "quantity": 1, "description": "Another description" }}
]
}}
```
Only include fields and products that are clearly identified from the provided information.
If you're not sure about a field or product, leave it blank or don't include it.
If you cannot identify any products, return an empty products array but still include any sale_order_fields you can identify.
Remember: Your entire response must be valid JSON wrapped in code blocks. No other text.
"""
try:
# Get the OpenWebUI client
client = self.env['openai.openwebui.client']
# Create the messages for the AI
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": prompt}
]
# Call the OpenWebUI API
_logger.info("Calling OpenWebUI API for ticket %s", self.id)
response = client.chat_completion(messages)
if response:
_logger.info("Received AI response for ticket %s: %s", self.id, response[:100])
# Store the AI-generated products
self.ai_generated_products = response
return True
else:
_logger.error("Empty response from OpenWebUI API for ticket %s", self.id)
return False
except Exception as e:
_logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, str(e))
import traceback
_logger.error("Traceback: %s", traceback.format_exc())
return False
# Note: This method is kept for compatibility but is no longer used
# The _ai_convert_to_sale_order method now creates the sale order directly
def _generate_ai_so_values(self):
"""Generate sale order values with AI-suggested products"""
# Start with the base SO values from the parent method
return self._generate_so_values()
def _parse_ai_product_suggestions(self):
"""Parse the AI-generated product suggestions into sale order lines and fields"""
result = {
'order_lines': [],
'sale_order_fields': {}
}
if not self.ai_generated_products:
_logger.warning("No AI generated products found for ticket %s", self.id)
return result['order_lines']
# Log the AI response for debugging
_logger.info("Parsing AI product suggestions for ticket %s: %s", self.id, self.ai_generated_products[:300])
# First, try to extract JSON from the response using multiple patterns
# Pattern 1: Standard code block with json tag
json_pattern1 = r'```(?:json)?\s*({[\s\S]*?})\s*```'
# Pattern 2: Just find any JSON-like structure with sale_order_fields or products
json_pattern2 = r'({[\s\S]*?"(?:sale_order_fields|products)"[\s\S]*?})'
# Pattern 3: Find any JSON-like structure (most permissive)
json_pattern3 = r'({\s*"[^"]+"\s*:.*})' # Any JSON object with at least one key
json_matches = re.findall(json_pattern1, self.ai_generated_products)
if not json_matches:
_logger.info("No JSON found with pattern 1, trying pattern 2")
json_matches = re.findall(json_pattern2, self.ai_generated_products)
if not json_matches:
_logger.info("No JSON found with pattern 2, trying pattern 3")
json_matches = re.findall(json_pattern3, self.ai_generated_products)
if json_matches:
# Try to parse the JSON
try:
# Clean up the JSON string before parsing
json_str = json_matches[0]
# Remove any trailing commas before closing brackets (common JSON error)
json_str = re.sub(r',\s*([\]\}])', r'\1', json_str)
json_data = json.loads(json_str)
_logger.info(f"Successfully parsed JSON data: {json_data}")
# Extract sale order fields
if 'sale_order_fields' in json_data:
result['sale_order_fields'] = json_data['sale_order_fields']
_logger.info(f"Extracted sale order fields: {result['sale_order_fields']}")
# Direct fields at root level (fallback)
elif any(key in json_data for key in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']):
so_fields = {}
for field in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']:
if field in json_data:
so_fields[field] = json_data[field]
result['sale_order_fields'] = so_fields
_logger.info(f"Extracted sale order fields from root level: {result['sale_order_fields']}")
# Extract products - check multiple possible keys
product_key = None
for key in ['products', 'product_suggestions', 'order_lines', 'items']:
if key in json_data and isinstance(json_data[key], list):
product_key = key
break
if product_key:
for product in json_data[product_key]:
if not isinstance(product, dict):
continue
product_name = product.get('name')
if not product_name:
continue
quantity = product.get('quantity', 1.0)
try:
quantity = float(quantity)
except (ValueError, TypeError):
quantity = 1.0
description = product.get('description', '')
_logger.info(f"Processing product from JSON: {product_name}, qty={quantity}, desc={description}")
order_line = self._create_product_order_line(product_name, quantity, description)
if order_line:
result['order_lines'].append(order_line)
_logger.info(f"Parsed {len(result['order_lines'])} order lines from JSON")
return result
except json.JSONDecodeError as e:
_logger.error(f"Failed to parse JSON: {e}")
# Try to extract just the sale order fields using regex as a last resort
try:
# Look for client_order_ref pattern
po_pattern = r'(?:client_order_ref|PO number|purchase order)[\s"]*[:=]\s*["]*([^"\n,}]+)'
po_match = re.search(po_pattern, self.ai_generated_products, re.IGNORECASE)
if po_match:
result['sale_order_fields']['client_order_ref'] = po_match.group(1).strip()
# Look for dates
date_pattern = r'(?:date_order|order date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
date_match = re.search(date_pattern, self.ai_generated_products, re.IGNORECASE)
if date_match:
result['sale_order_fields']['date_order'] = date_match.group(1)
# Look for commitment date
commit_pattern = r'(?:commitment_date|delivery date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
commit_match = re.search(commit_pattern, self.ai_generated_products, re.IGNORECASE)
if commit_match:
result['sale_order_fields']['commitment_date'] = commit_match.group(1)
# Look for payment terms
payment_pattern = r'(?:payment_term_id|payment terms)[\s"]*[:=]\s*["]*([^"\n,}]+)'
payment_match = re.search(payment_pattern, self.ai_generated_products, re.IGNORECASE)
if payment_match:
result['sale_order_fields']['payment_term_id'] = payment_match.group(1).strip()
if result['sale_order_fields']:
_logger.info(f"Extracted sale order fields using regex: {result['sale_order_fields']}")
except Exception as regex_error:
_logger.error(f"Error in regex extraction fallback: {regex_error}")
# If JSON parsing failed, fall back to the old parsing methods
_logger.info("Falling back to legacy parsing methods")
# Try to extract reference number using regex before falling back to line-by-line parsing
ref_patterns = [
r'(?:reference|ticket|po|purchase order)[\s\-]*(?:number|#)?[\s\-:]*([\d\-]+)',
r'(?:client_order_ref|order ref)[\s"]*[:=]\s*["]*([^"\n,}]+)'
]
for pattern in ref_patterns:
ref_match = re.search(pattern, self.ai_generated_products, re.IGNORECASE)
if ref_match:
ref_number = ref_match.group(1).strip()
_logger.info(f"Found reference number using regex: {ref_number}")
result['sale_order_fields']['client_order_ref'] = ref_number
break
order_lines = []
# Try to parse the AI response in different formats
# First, look for a table format with | separators
table_pattern = r"([^|\n]+)\s*\|\s*(\d*\.?\d*)\s*\|\s*([^|\n]*)"
table_matches = re.findall(table_pattern, self.ai_generated_products)
if table_matches:
# Process table format
_logger.info(f"Found table format with {len(table_matches)} matches")
for match in table_matches:
product_name = match[0].strip()
if not product_name or product_name.lower() in ['product/service name', 'product', 'service', 'item']:
continue
# Parse quantity
quantity = 1.0
if match[1].strip():
try:
quantity = float(match[1].strip())
except ValueError:
quantity = 1.0
# Get description
description = match[2].strip() if match[2].strip() else product_name
# Add the order line
order_line = self._create_product_order_line(product_name, quantity, description)
if order_line:
order_lines.append(order_line)
else:
# Try to parse line by line for products and quantities
# Look for patterns like "2x Product Name" or "Product Name (qty: 3)" or "Product Name - 4 units"
lines = self.ai_generated_products.strip().split('\n')
_logger.info(f"Parsing line by line, found {len(lines)} lines")
# Skip header lines and empty lines
processed_lines = []
for line in lines:
line = line.strip()
# Skip empty lines, headers, and other non-product lines
if (not line or
line.startswith('#') or
line.lower().startswith('product') or
line.lower() == 'format your response as' or
line.lower() == 'for example:'):
continue
# Remove bullet points and other common prefixes
line = re.sub(r'^[-*\u2022]\s*', '', line)
processed_lines.append(line)
for line in processed_lines:
_logger.info(f"Processing line: {line}")
# Try to extract quantity, product name, part number, and description
# Format examples:
# - 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency
# - 1x Preventive Maintenance Service: Annual service package
# - 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI
# Pattern for the format specified in the prompt template
detailed_pattern = r"(\d+)x\s+([^:]+?)(?:\s+([A-Z0-9][A-Z0-9-]+))?\s*:?\s*(.*)"
match = re.search(detailed_pattern, line, re.IGNORECASE)
if match:
quantity = float(match.group(1))
product_name = match.group(2).strip()
part_number = match.group(3) if match.group(3) else ''
specs = match.group(4).strip() if match.group(4) else ''
# Combine part number with product name if available
if part_number:
full_product_name = f"{product_name} {part_number}"
else:
full_product_name = product_name
# Use specifications as description if available
description = specs if specs else product_name
_logger.info(f"Matched detailed pattern: qty={quantity}, product={full_product_name}, desc={description}")
order_line = self._create_product_order_line(full_product_name, quantity, description)
if order_line:
order_lines.append(order_line)
continue
# Try other common patterns if the detailed pattern didn't match
qty_patterns = [
r"(\d+(?:\.\d+)?)\s*x\s*([^\d\n]+)", # "2x Product Name" or "2.5x Product Name"
r"([^\d\n]+)\s*\(\s*qty\s*:\s*(\d+(?:\.\d+)?)\s*\)", # "Product Name (qty: 3)"
r"([^\d\n]+)\s*-\s*(\d+(?:\.\d+)?)\s*units?", # "Product Name - 4 units"
r"([^\d\n]+)\s*:\s*(\d+(?:\.\d+)?)", # "Product Name: 2"
r"quantity\s*:\s*(\d+(?:\.\d+)?)\s*,?\s*([^,]+)", # "Quantity: 2, Product Name"
]
product_name = None
quantity = 1.0
description = ""
for pattern in qty_patterns:
match = re.search(pattern, line, re.IGNORECASE)
if match:
if pattern == qty_patterns[0]: # "2x Product Name"
try:
quantity = float(match.group(1))
product_name = match.group(2).strip()
except (ValueError, IndexError):
continue
else: # Other patterns
try:
product_name = match.group(1).strip()
quantity = float(match.group(2))
except (ValueError, IndexError):
continue
# Try to extract description after the product name
desc_match = re.search(r"[^:]+:(.+)$", line)
if desc_match:
description = desc_match.group(1).strip()
_logger.info(f"Matched pattern {pattern}: qty={quantity}, product={product_name}, desc={description}")
break
# If no pattern matched, use the whole line as product name
if not product_name:
# Check if there's a colon that might separate product name from description
if ':' in line:
parts = line.split(':', 1)
product_name = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else ''
else:
product_name = line
description = ''
_logger.info(f"No pattern match, using line as product: {product_name}, desc={description}")
# Add the order line
order_line = self._create_product_order_line(product_name, quantity, description)
if order_line:
order_lines.append(order_line)
result['order_lines'] = order_lines
_logger.info(f"Parsed {len(order_lines)} order lines from AI suggestions")
return result
def _create_product_order_line(self, product_name, quantity, description=""):
"""Create a sale order line for a product"""
if not product_name:
return False
# Search for matching product - try exact match first
product = self.env['product.product'].search([
('name', '=', product_name),
('sale_ok', '=', True)
], limit=1)
# If no exact match, try partial match
if not product:
product = self.env['product.product'].search([
('name', 'ilike', product_name),
('sale_ok', '=', True)
], limit=1)
# If still no product found, try matching by default_code (SKU/part number)
if not product and any(c.isdigit() for c in product_name): # Check if product name contains numbers (likely a part number)
# Extract potential part numbers
part_numbers = re.findall(r'[A-Z0-9][A-Z0-9-]+', product_name)
for part in part_numbers:
product = self.env['product.product'].search([
('default_code', '=', part),
('sale_ok', '=', True)
], limit=1)
if product:
break
# If no product found, log it and return False
if not product:
_logger.info(f"No matching product found for: {product_name}")
return False
# Create order line with price information
line_values = {
'product_id': product.id,
'product_uom_qty': quantity,
'name': description or product.name,
}
# We don't need to set the price here - Odoo will handle this automatically
# when the sale order line is created with the product
# Just log the product's list price for debugging
_logger.info(f"Product {product.name} (ID: {product.id}) has list_price: {product.list_price}")
# We intentionally don't set price_unit here to let Odoo's standard mechanisms handle it
return (0, 0, line_values)