This commit represents a significant architectural improvement: - Completely replaced openwebui_connector with a more robust openwebui_base module - Centralized OpenWebUI API integration for better maintainability - Redesigned helpdesk_sale_order_ai to use the new openwebui_base module - Added support for multiple OpenWebUI providers and models - Improved error handling and response parsing - Added proper template management with openwebui_prompt_template - Fixed KeyError issues with safe template substitution - Streamlined API client initialization and usage
1629 lines
77 KiB
Python
1629 lines
77 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__)
|
|
|
|
# Import the client model to ensure it's loaded
|
|
from . import ai_openwebui_client
|
|
|
|
|
|
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}")
|
|
|
|
# Check if there are missing products and post a message
|
|
missing_products = ai_data.get('missing_products', [])
|
|
if missing_products:
|
|
missing_products_html = "<div style='font-size: 13px;'>"
|
|
missing_products_html += "<p><strong>Products not found in database:</strong></p>"
|
|
missing_products_html += "<ul style='margin-top: 4px; margin-bottom: 4px;'>"
|
|
|
|
for product in missing_products:
|
|
product_name = product.get('name', '')
|
|
quantity = product.get('quantity', 1.0)
|
|
description = product.get('description', '')
|
|
|
|
missing_products_html += "<li>"
|
|
missing_products_html += f"<strong>{product_name}</strong>"
|
|
|
|
# Always show quantity
|
|
missing_products_html += f" (QTY: {quantity})"
|
|
|
|
# Add description if available
|
|
if description:
|
|
missing_products_html += f" - {description}"
|
|
|
|
missing_products_html += "</li>"
|
|
|
|
missing_products_html += "</ul>"
|
|
missing_products_html += "<p><em>Please add these products manually or create them in the system.</em></p>"
|
|
missing_products_html += "</div>"
|
|
missing_products_html += "<!-- MISSING_PRODUCTS_MESSAGE -->"
|
|
|
|
# Post the message on the sale order
|
|
sale_order.message_post(body=missing_products_html, body_is_html=True, subtype_id=self.env.ref('mail.mt_note').id)
|
|
|
|
# Update the sale order to indicate it has missing products
|
|
sale_order.write({
|
|
'missing_product_count': len(missing_products),
|
|
'has_missing_products': True
|
|
})
|
|
|
|
_logger.info(f"Posted message about {len(missing_products)} missing products on sale order {sale_order.id}")
|
|
|
|
# Add order lines to the sale order from the AI data if available
|
|
order_lines = ai_data.get('order_lines', [])
|
|
_logger.info(f"Adding {len(order_lines)} order lines to sale order {sale_order.id}")
|
|
|
|
for line_values in order_lines:
|
|
if not line_values:
|
|
continue
|
|
|
|
_logger.info(f"Processing order line: {line_values}")
|
|
|
|
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
|
|
|
|
try:
|
|
order_line = self.env['sale.order.line'].create(initial_values)
|
|
_logger.info(f"Created order line with ID {order_line.id}")
|
|
|
|
# No need to create the order line twice - the above create is sufficient
|
|
except Exception as e:
|
|
_logger.error(f"Error creating order line: {e}")
|
|
continue
|
|
|
|
# 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 _prepare_ai_prompt_data(self):
|
|
"""Prepare data for the AI prompt template"""
|
|
self.ensure_one()
|
|
|
|
# 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:
|
|
# Process messages in reverse chronological order (newest first)
|
|
for message in self.message_ids.sorted(key=lambda m: m.id, reverse=True):
|
|
if message.body and not message.is_internal:
|
|
# Extract text from HTML more carefully
|
|
from html import unescape
|
|
# First unescape any HTML entities
|
|
unescaped_body = unescape(message.body)
|
|
# Then remove HTML tags but preserve line breaks
|
|
body_text = re.sub(r'<br\s*/?>', '\n', unescaped_body, flags=re.IGNORECASE)
|
|
body_text = re.sub(r'<[^>]+>', ' ', body_text)
|
|
# Clean up excessive whitespace
|
|
body_text = re.sub(r'\s+', ' ', body_text).strip()
|
|
|
|
# Add message to chatter with author and date for context
|
|
date_str = message.date.strftime('%Y-%m-%d') if message.date else 'Unknown date'
|
|
chatter_messages += f"Message from {message.author_id.name or 'Unknown'} on {date_str}: {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)}")
|
|
|
|
# Return the prepared data as a dictionary for template formatting
|
|
return {
|
|
'ticket_description': description,
|
|
'ticket_messages': chatter_messages,
|
|
'attachments_info': attachments_info,
|
|
'attachment_contents': attachment_contents
|
|
}
|
|
|
|
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 data
|
|
ticket_data = self._prepare_ai_prompt_data()
|
|
description = ticket_data['ticket_description']
|
|
chatter_messages = ticket_data['ticket_messages']
|
|
attachments_info = ticket_data['attachments_info']
|
|
attachment_contents = ticket_data['attachment_contents']
|
|
|
|
# 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 (IMPORTANT - CAREFULLY ANALYZE THESE FOR PRODUCT INFORMATION):
|
|
{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 (e.g., SO26321, Contract Number, etc.)
|
|
- 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", "Net 90 Days")
|
|
|
|
2. Extract products or services from ALL sources (ticket description, chatter messages, and attachments). Pay special attention to:
|
|
- Line items in tables or structured formats
|
|
- Product codes/SKUs (e.g., PS-0600-4L, CPGPD-20N000BEE)
|
|
- Part numbers in brackets like [1231541w] or similar formats
|
|
- Exact product names as they appear in any message
|
|
- Quantities and units (including formats like "3x" or "qty: 5")
|
|
- Descriptions that include specifications
|
|
- Informal product requests in chatter messages (e.g., "I would like to also buy 3 of [1231541w]These-nuts")
|
|
|
|
IMPORTANT INSTRUCTIONS FOR COMPLEX DOCUMENTS:
|
|
- If the document is a quote, invoice, or similar structured document, extract EACH LINE ITEM exactly as it appears
|
|
- Include the EXACT product code/SKU if present (e.g., [FEE-TECH-SPEC], [PS-0600-4L], [CPGPD-20N000BEE])
|
|
- For each product, include the EXACT name, quantity, and full description
|
|
- Do not summarize or combine line items
|
|
- Do not skip any products listed in the document
|
|
- If a product has a part number in brackets like [ABC-123], include it in the name field
|
|
|
|
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": "[ABC-123] Product Name", "quantity": 2, "description": "Full product description with all specifications" }},
|
|
{{ "name": "[DEF-456] Another Product", "quantity": 1, "description": "Another full description" }}
|
|
]
|
|
}}
|
|
```
|
|
"""
|
|
|
|
# Get the AI client from the openwebui_base module
|
|
try:
|
|
_logger.info("Using OpenWebUI client for ticket %s", self.id)
|
|
|
|
# Use the openwebui.client model that we've defined as a bridge
|
|
# This model handles the connection to the OpenWebUI API
|
|
# The model is defined in ai_openwebui_client.py and imported at the top of this file
|
|
ai_client = self.env['openwebui.client'].sudo()
|
|
|
|
# Get the prompt template from the team settings or default
|
|
template_content = None
|
|
if self.team_id:
|
|
template_content = self.team_id._get_ai_prompt_template()
|
|
|
|
# If a template is found, use it instead of the hardcoded prompt
|
|
if template_content:
|
|
# Replace placeholders in the template
|
|
prompt = template_content.format(
|
|
description=description,
|
|
chatter_messages=chatter_messages,
|
|
attachments_info=attachments_info,
|
|
attachment_contents=attachment_contents
|
|
)
|
|
_logger.info("Using custom prompt template for ticket %s", self.id)
|
|
|
|
# Call the OpenWebUI API using the client model
|
|
# The chat_completion method is defined in the AIOpenWebUIClient class
|
|
_logger.info("Sending prompt to OpenWebUI API")
|
|
response = ai_client.chat_completion(
|
|
messages=[{"role": "user", "content": prompt}],
|
|
model="anthropic.claude-3-7-sonnet-latest" # Use the default model from memory
|
|
)
|
|
|
|
# Extract the response content
|
|
if response and response.get('choices') and response['choices'][0].get('message'):
|
|
ai_response = response['choices'][0]['message'].get('content', '')
|
|
_logger.info("Received AI response for ticket %s (length: %s)", self.id, len(ai_response))
|
|
|
|
# Save the AI response to the ticket
|
|
self.ai_generated_products = ai_response
|
|
return True
|
|
else:
|
|
_logger.error("Invalid response format from OpenWebUI API: %s", response)
|
|
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
|
|
|
|
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}")
|
|
|
|
# Create the sale order with the prepared values
|
|
SaleOrder = self.env['sale.order']
|
|
sale_order = SaleOrder.create(so_values)
|
|
_logger.info(f"Created sale order {sale_order.name} (ID: {sale_order.id}) for ticket {self.id}")
|
|
|
|
# Add order lines from AI data
|
|
if ai_data.get('order_lines'):
|
|
_logger.info(f"Adding {len(ai_data['order_lines'])} order lines to sale order {sale_order.name}")
|
|
sale_order.write({
|
|
'order_line': ai_data['order_lines']
|
|
})
|
|
|
|
# Link the sale order to the ticket
|
|
self.write({
|
|
'sale_order_id': sale_order.id,
|
|
})
|
|
|
|
# Return the action to open 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,tree',
|
|
'context': self.env.context,
|
|
}
|
|
|
|
def _prepare_ai_prompt_data(self):
|
|
"""Prepare data for the AI prompt template"""
|
|
self.ensure_one()
|
|
|
|
# Get ticket data
|
|
ticket_data = {
|
|
'name': self.name or '',
|
|
'description': self.description or '',
|
|
'partner_name': self.partner_id.name if self.partner_id else '',
|
|
'category': self.category_id.name if self.category_id else '',
|
|
'team': self.team_id.name if self.team_id else '',
|
|
'priority': dict(self._fields['priority'].selection).get(self.priority, ''),
|
|
'create_date': self.create_date.strftime('%Y-%m-%d %H:%M:%S') if self.create_date else '',
|
|
}
|
|
|
|
# Get chatter messages (excluding internal notes)
|
|
messages = []
|
|
for message in self.message_ids:
|
|
# Skip internal notes
|
|
if message.message_type == 'comment' and not message.subtype_id.internal:
|
|
author = message.author_id.name if message.author_id else 'System'
|
|
date = message.date.strftime('%Y-%m-%d %H:%M:%S') if message.date else ''
|
|
body = message.body or ''
|
|
|
|
# Remove HTML tags from body
|
|
body = re.sub('<.*?>', ' ', body)
|
|
body = re.sub('\s+', ' ', body).strip()
|
|
|
|
if body: # Only include non-empty messages
|
|
messages.append({
|
|
'author': author,
|
|
'date': date,
|
|
'body': body
|
|
})
|
|
|
|
# Get attachment information
|
|
attachments = []
|
|
for attachment in self.attachment_ids:
|
|
if attachment.mimetype and ('text/' in attachment.mimetype or 'application/pdf' in attachment.mimetype):
|
|
try:
|
|
# For text files, try to get the content
|
|
if 'text/' in attachment.mimetype and attachment.datas:
|
|
import base64
|
|
content = base64.b64decode(attachment.datas).decode('utf-8', errors='ignore')
|
|
# Truncate long content
|
|
if len(content) > 1000:
|
|
content = content[:1000] + '... [truncated]'
|
|
else:
|
|
content = '[Binary content not extracted]'
|
|
|
|
attachments.append({
|
|
'name': attachment.name or '',
|
|
'mimetype': attachment.mimetype or '',
|
|
'content': content
|
|
})
|
|
except Exception as e:
|
|
_logger.error(f"Error processing attachment {attachment.name}: {e}")
|
|
|
|
# Get available products (limit to reasonable number to avoid token limits)
|
|
products = []
|
|
product_records = self.env['product.product'].search([('sale_ok', '=', True)], limit=100)
|
|
|
|
for product in product_records:
|
|
products.append({
|
|
'id': product.id,
|
|
'name': product.name,
|
|
'default_code': product.default_code or '',
|
|
'list_price': product.list_price,
|
|
'description': product.description_sale or '',
|
|
'category': product.categ_id.name if product.categ_id else '',
|
|
'uom': product.uom_id.name if product.uom_id else '',
|
|
})
|
|
|
|
# Combine all data
|
|
prompt_data = {
|
|
'ticket': ticket_data,
|
|
'messages': messages,
|
|
'attachments': attachments,
|
|
'products': products,
|
|
}
|
|
|
|
return prompt_data
|
|
|
|
def _generate_ai_product_suggestions(self):
|
|
"""Use AI to generate product suggestions based on ticket description, chatter messages and attachments"""
|
|
self.ensure_one()
|
|
|
|
if not self.description and not self.message_ids:
|
|
_logger.warning("Cannot generate AI suggestions: ticket %s has no description or messages", self.id)
|
|
return {'success': False, 'error': 'Ticket has no description or messages'}
|
|
|
|
try:
|
|
# Prepare data for the AI prompt
|
|
prompt_data = self._prepare_ai_prompt_data()
|
|
|
|
# Get the OpenWebUI client
|
|
client = self.env['helpdesk.openwebui.client']
|
|
|
|
# Prepare the system message
|
|
system_message = """
|
|
You are a helpful AI assistant for a helpdesk system. Your task is to analyze the ticket description,
|
|
messages, and attachments to suggest products that should be included in a sales order for this ticket.
|
|
|
|
For each product suggestion, provide:
|
|
1. The product name that best matches a product in our database
|
|
2. The quantity needed
|
|
3. A brief description or note if needed
|
|
|
|
Format your response as a JSON object with the following structure:
|
|
```json
|
|
{
|
|
"products": [
|
|
{
|
|
"name": "Product Name",
|
|
"quantity": 1,
|
|
"description": "Optional description"
|
|
},
|
|
...
|
|
],
|
|
"sale_order_fields": {
|
|
"client_order_ref": "Customer PO number if mentioned",
|
|
"date_order": "YYYY-MM-DD format if a specific date is mentioned",
|
|
"note": "Any additional notes for the sale order"
|
|
}
|
|
}
|
|
```
|
|
|
|
If you can't find any product suggestions, return an empty products array.
|
|
Use the product database information provided to match products accurately.
|
|
If you see a part number or product code mentioned, prioritize matching based on that.
|
|
"""
|
|
|
|
# Prepare the user message with the ticket data
|
|
user_message = f"""
|
|
Please suggest products for the following helpdesk ticket:
|
|
|
|
## Ticket Information
|
|
- Name: {prompt_data['ticket']['name']}
|
|
- Description: {prompt_data['ticket']['description']}
|
|
- Customer: {prompt_data['ticket']['partner_name']}
|
|
- Category: {prompt_data['ticket']['category']}
|
|
- Team: {prompt_data['ticket']['team']}
|
|
- Priority: {prompt_data['ticket']['priority']}
|
|
- Created on: {prompt_data['ticket']['create_date']}
|
|
|
|
## Messages
|
|
{"\n".join([f"- {m['date']} - {m['author']}: {m['body']}" for m in prompt_data['messages']][:10]) if prompt_data['messages'] else "No messages"}
|
|
|
|
## Attachments
|
|
{"\n".join([f"- {a['name']} ({a['mimetype']}): {a['content'][:200] + '...' if len(a['content']) > 200 else a['content']}" for a in prompt_data['attachments']][:3]) if prompt_data['attachments'] else "No attachments"}
|
|
|
|
## Available Products in Database
|
|
{json.dumps(prompt_data['products'], indent=2)}
|
|
"""
|
|
|
|
# Prepare the messages for the AI
|
|
messages = [
|
|
{"role": "system", "content": system_message},
|
|
{"role": "user", "content": user_message}
|
|
]
|
|
|
|
# Call the AI service
|
|
_logger.info("Calling OpenWebUI client for ticket %s", self.id)
|
|
response = client.chat_completion(messages)
|
|
|
|
if not response:
|
|
_logger.error("Failed to get response from OpenWebUI client for ticket %s", self.id)
|
|
return {'success': False, 'error': 'Failed to get response from AI service'}
|
|
|
|
# Extract the content from the response
|
|
ai_content = response.get('choices', [{}])[0].get('message', {}).get('content', '')
|
|
|
|
if not ai_content:
|
|
_logger.error("Empty content in AI response for ticket %s", self.id)
|
|
return {'success': False, 'error': 'Empty content in AI response'}
|
|
|
|
# Save the AI response to the ticket
|
|
self.write({
|
|
'ai_generated_products': ai_content
|
|
})
|
|
|
|
_logger.info("Successfully generated AI product suggestions for ticket %s", self.id)
|
|
return {'success': True}
|
|
|
|
except Exception as e:
|
|
_logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, e)
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
# End of _generate_ai_product_suggestions method
|
|
|
|
def _parse_ai_product_suggestions(self):
|
|
"""Parse AI-generated product suggestions into sale order lines
|
|
|
|
This method parses the AI-generated product suggestions from the ticket's ai_generated_products field
|
|
and converts them into sale order lines. It handles various formats including JSON, table format,
|
|
and line-by-line parsing.
|
|
|
|
Returns:
|
|
dict: A dictionary containing sale order fields and order lines
|
|
"""
|
|
result = {
|
|
'order_lines': [],
|
|
'sale_order_fields': {},
|
|
'missing_products': []
|
|
}
|
|
|
|
if not self.ai_generated_products:
|
|
_logger.warning("No AI generated products found for ticket %s", self.id)
|
|
return result
|
|
'id': self.partner_id.id,
|
|
'name': self.partner_id.name,
|
|
'is_company': self.partner_id.is_company,
|
|
'parent_id': self.partner_id.parent_id.id if self.partner_id.parent_id else False,
|
|
'parent_name': self.partner_id.parent_id.name if self.partner_id.parent_id else '',
|
|
})
|
|
|
|
if self.partner_id.parent_id:
|
|
partners.append({
|
|
'id': self.partner_id.parent_id.id,
|
|
'name': self.partner_id.parent_id.name,
|
|
'is_company': self.partner_id.parent_id.is_company,
|
|
'parent_id': False,
|
|
'parent_name': '',
|
|
})
|
|
|
|
# Prepare ticket data
|
|
ticket_data = {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'description': self.description or '',
|
|
'partner_id': self.partner_id.id if self.partner_id else False,
|
|
'partner_name': self.partner_id.name if self.partner_id else '',
|
|
'team_id': self.team_id.id if self.team_id else False,
|
|
'team_name': self.team_id.name if self.team_id else '',
|
|
}
|
|
|
|
# Get message history
|
|
messages_data = []
|
|
if self.message_ids:
|
|
for message in self.message_ids:
|
|
if message.body and not message.is_internal:
|
|
messages_data.append({
|
|
'id': message.id,
|
|
'date': message.date,
|
|
'author': message.author_id.name if message.author_id else 'System',
|
|
'body': message.body,
|
|
})
|
|
|
|
# Prepare the prompt for the AI
|
|
messages = [
|
|
{
|
|
'role': 'system',
|
|
'content': f'''
|
|
You are an expert sales assistant for a pneumatics company. Your task is to analyze a helpdesk ticket
|
|
and create a complete sales order structure with matched products from the database.
|
|
|
|
Return your response as a JSON object with the following structure:
|
|
{{
|
|
"sale_order_fields": {{
|
|
"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"
|
|
}},
|
|
"products": [
|
|
{{
|
|
"product_id": 123, // The ID of the matched product from the database
|
|
"name": "Product name", // For reference only
|
|
"quantity": 2.0, // Quantity needed
|
|
"description": "Any special notes about this line item"
|
|
}},
|
|
// Additional products...
|
|
]
|
|
}}
|
|
|
|
If you can't find an exact product match in the database, include as much detail as possible
|
|
about what the customer needs so a new product can be created.
|
|
'''
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'content': f'''
|
|
Helpdesk Ticket: {ticket_data}
|
|
|
|
Message History: {messages_data}
|
|
|
|
Available Products: {product_data}
|
|
|
|
Partner Information: {partners}
|
|
|
|
Please analyze this ticket and create a complete sales order structure with matched products.
|
|
'''
|
|
}
|
|
]
|
|
|
|
# Call the OpenWebUI client
|
|
try:
|
|
client = self.env['openwebui.client']
|
|
response = client.chat_completion(messages)
|
|
|
|
if not response:
|
|
_logger.error("Failed to get response from OpenWebUI client")
|
|
return result
|
|
|
|
# Extract the content from the response
|
|
ai_content = response.get('choices', [{}])[0].get('message', {}).get('content', '{}')
|
|
_logger.info(f"AI response: {ai_content[:500]}")
|
|
|
|
# Try to extract JSON from the response
|
|
json_pattern = r'```(?:json)?\s*({[\s\S]*?})\s*```'
|
|
json_matches = re.findall(json_pattern, ai_content)
|
|
|
|
if json_matches:
|
|
try:
|
|
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)
|
|
|
|
parsed_data = json.loads(json_str)
|
|
_logger.info(f"Parsed AI result: {parsed_data}")
|
|
|
|
# Extract sale order fields
|
|
if 'sale_order_fields' in parsed_data:
|
|
result['sale_order_fields'] = parsed_data['sale_order_fields']
|
|
|
|
# Extract products and create order lines
|
|
products_key = None
|
|
for key in ['products', 'product_suggestions', 'order_lines', 'items']:
|
|
if key in parsed_data and isinstance(parsed_data[key], list):
|
|
products_key = key
|
|
break
|
|
|
|
if products_key:
|
|
for product_item in parsed_data[products_key]:
|
|
if not isinstance(product_item, dict):
|
|
continue
|
|
|
|
product_id = product_item.get('product_id')
|
|
quantity = product_item.get('quantity', 1.0)
|
|
description = product_item.get('description', '')
|
|
|
|
try:
|
|
quantity = float(quantity)
|
|
except (ValueError, TypeError):
|
|
quantity = 1.0
|
|
|
|
if product_id:
|
|
# Direct product ID match
|
|
product = self.env['product.product'].browse(product_id)
|
|
if product.exists():
|
|
order_line = self._prepare_order_line_values(product, quantity, description)
|
|
result['order_lines'].append(order_line)
|
|
continue
|
|
|
|
# If no direct product ID or it doesn't exist, try to find by name
|
|
product_name = product_item.get('name')
|
|
if product_name:
|
|
order_line = self._create_product_order_line(product_name, quantity, description)
|
|
if order_line:
|
|
result['order_lines'].append(order_line)
|
|
else:
|
|
# Track missing products
|
|
missing_product_info = {
|
|
'name': product_name,
|
|
'quantity': quantity,
|
|
'description': description
|
|
}
|
|
result['missing_products'].append(missing_product_info)
|
|
|
|
return result
|
|
|
|
except (json.JSONDecodeError, Exception) as e:
|
|
_logger.error(f"Error parsing AI response JSON: {e}")
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error using AI for parsing suggestions: {e}")
|
|
|
|
# If AI parsing failed, return empty result
|
|
return result
|
|
|
|
def _parse_ai_product_suggestions(self):
|
|
"""Parse AI-generated product suggestions into sale order lines
|
|
|
|
This method parses the AI-generated product suggestions from the ticket's ai_generated_products field
|
|
and converts them into sale order lines. It handles various formats including JSON, table format,
|
|
and line-by-line parsing.
|
|
|
|
Returns:
|
|
dict: A dictionary containing sale order fields and order lines
|
|
"""
|
|
result = {
|
|
'order_lines': [],
|
|
'sale_order_fields': {},
|
|
'missing_products': []
|
|
}
|
|
|
|
if not self.ai_generated_products:
|
|
_logger.warning("No AI generated products found for ticket %s", self.id)
|
|
return result
|
|
|
|
# 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)
|
|
else:
|
|
# Track missing products
|
|
missing_product_info = {
|
|
'name': product_name,
|
|
'quantity': quantity,
|
|
'description': description
|
|
}
|
|
result['missing_products'].append(missing_product_info)
|
|
|
|
_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 _ai_find_product_match(self, product_info):
|
|
"""
|
|
Use OpenWebUI client to find a matching product in the database
|
|
|
|
Args:
|
|
product_info: Dictionary containing product information (name, quantity, description, etc.)
|
|
|
|
Returns:
|
|
Tuple of (product, confidence_score) if found, or (False, 0) if not found
|
|
"""
|
|
_logger.info(f"Using AI to find product match for: {product_info}")
|
|
|
|
# Get all available products that can be sold
|
|
available_products = self.env['product.product'].search([('sale_ok', '=', True)])
|
|
|
|
# Prepare product data for the AI - include more relevant fields for better matching
|
|
product_data = []
|
|
for product in available_products:
|
|
product_data.append({
|
|
'id': product.id,
|
|
'name': product.name,
|
|
'default_code': product.default_code or '',
|
|
'description': product.description or '',
|
|
'description_sale': product.description_sale or '',
|
|
'categ_id': product.categ_id.name if product.categ_id else '',
|
|
'list_price': product.list_price,
|
|
'uom_name': product.uom_id.name if product.uom_id else '',
|
|
})
|
|
|
|
# Extract key information from product_info for better matching
|
|
product_name = product_info.get('name', '')
|
|
description = product_info.get('description', '')
|
|
|
|
# Try to extract part numbers from the product name
|
|
part_numbers = []
|
|
bracket_match = re.search(r'\[(.*?)\]', product_name)
|
|
if bracket_match:
|
|
part_numbers.append(bracket_match.group(1).strip())
|
|
|
|
# Extract additional part numbers using patterns
|
|
part_number_patterns = [
|
|
r'[A-Z][A-Z0-9\-]{3,}', # Model numbers like ABC-123
|
|
r'[A-Z]+-[A-Z0-9]+', # Part numbers with specific formats
|
|
r'[A-Z]+-[0-9]+-[A-Z0-9]+' # Product codes with specific prefixes
|
|
]
|
|
|
|
for pattern in part_number_patterns:
|
|
matches = re.findall(pattern, product_name, re.IGNORECASE)
|
|
if matches:
|
|
part_numbers.extend(matches)
|
|
|
|
# Filter out duplicates and short part numbers
|
|
part_numbers = list(set([p for p in part_numbers if p and len(p) >= 4]))
|
|
|
|
# Prepare the prompt for the AI with enhanced context
|
|
messages = [
|
|
{
|
|
'role': 'system',
|
|
'content': f'''
|
|
You are a product matching expert for an industrial equipment company. Your task is to find the best matching product
|
|
from the database based on the provided product information. You have access to the following product attributes:
|
|
id, name, default_code (SKU/part number), description, description_sale, category, list_price, and unit of measure.
|
|
|
|
When matching products, consider the following priority order:
|
|
1. Exact match on default_code (part number)
|
|
2. Exact match on product name
|
|
3. Partial match on default_code
|
|
4. Partial match on product name with high similarity
|
|
5. Match based on description and category
|
|
|
|
Return your response as a JSON object with the following structure:
|
|
{{
|
|
"matched_product_id": 123, // The ID of the matched product, or null if no match found
|
|
"confidence_score": 0.95, // A score between 0 and 1 indicating your confidence in the match
|
|
"reasoning": "Explanation of why this product was matched"
|
|
}}
|
|
|
|
Only return a match if you are reasonably confident (score > 0.7). Otherwise, return null for matched_product_id.
|
|
'''
|
|
},
|
|
{
|
|
'role': 'user',
|
|
'content': f'''
|
|
Product to match: {{
|
|
"name": "{product_name}",
|
|
"description": "{description}",
|
|
"quantity": {product_info.get('quantity', 1.0)},
|
|
"extracted_part_numbers": {part_numbers}
|
|
}}
|
|
|
|
Available products in database (showing first 100 of {len(product_data)} products):
|
|
{json.dumps(product_data[:100], indent=2)}
|
|
'''
|
|
}
|
|
]
|
|
|
|
# Call the OpenWebUI client
|
|
try:
|
|
client = self.env['helpdesk.openwebui.client']
|
|
response = client.chat_completion(messages)
|
|
|
|
if not response:
|
|
_logger.error("Failed to get response from OpenWebUI client")
|
|
return False, 0
|
|
|
|
# Extract the content from the response
|
|
ai_content = response.get('choices', [{}])[0].get('message', {}).get('content', '{}')
|
|
_logger.info(f"AI response: {ai_content[:500]}")
|
|
|
|
# Try multiple JSON extraction patterns
|
|
json_patterns = [
|
|
r'```(?:json)?\s*({[\s\S]*?})\s*```', # Code block with JSON
|
|
r'({\s*"matched_product_id"[\s\S]*?})(?:\s*$|\n)', # JSON starting with matched_product_id
|
|
r'({[\s\S]*?"matched_product_id"[\s\S]*?})(?:\s*$|\n)', # Any JSON containing matched_product_id
|
|
r'({[\s\S]*?"confidence_score"[\s\S]*?})(?:\s*$|\n)' # Any JSON containing confidence_score
|
|
]
|
|
|
|
parsed_result = None
|
|
|
|
# Try each pattern in order
|
|
for pattern in json_patterns:
|
|
json_matches = re.findall(pattern, ai_content)
|
|
if json_matches:
|
|
for json_str in json_matches:
|
|
try:
|
|
# Clean up the JSON string
|
|
# Remove any trailing commas before closing brackets (common JSON error)
|
|
json_str = re.sub(r',\s*([\]\}])', r'\1', json_str)
|
|
# Fix missing quotes around keys (another common error)
|
|
json_str = re.sub(r'([{,])\s*(\w+)\s*:', r'\1"\2":', json_str)
|
|
|
|
result = json.loads(json_str)
|
|
_logger.info(f"Parsed AI result: {result}")
|
|
|
|
# Check if this JSON has the fields we need
|
|
if 'matched_product_id' in result:
|
|
parsed_result = result
|
|
break
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if parsed_result:
|
|
break
|
|
|
|
# If we found a valid JSON result
|
|
if parsed_result:
|
|
# Get the matched product ID and confidence score
|
|
product_id = parsed_result.get('matched_product_id')
|
|
confidence_score = parsed_result.get('confidence_score', 0)
|
|
reasoning = parsed_result.get('reasoning', 'No reasoning provided')
|
|
|
|
_logger.info(f"AI reasoning: {reasoning}")
|
|
|
|
# Handle null/None product_id
|
|
if product_id is None or product_id == 'null' or product_id == 'None':
|
|
_logger.info("AI couldn't find a confident match")
|
|
return False, 0
|
|
|
|
# Convert string ID to int if needed
|
|
if isinstance(product_id, str) and product_id.isdigit():
|
|
product_id = int(product_id)
|
|
|
|
if product_id and confidence_score > 0.7: # Only accept matches with high confidence
|
|
try:
|
|
product = self.env['product.product'].browse(product_id)
|
|
if product.exists():
|
|
_logger.info(f"AI found product match: {product.name} (ID: {product.id}) with confidence {confidence_score}")
|
|
return product, confidence_score
|
|
except Exception as e:
|
|
_logger.error(f"Error retrieving product: {e}")
|
|
|
|
# If JSON parsing failed, try regex extraction as a last resort
|
|
id_pattern = r'"matched_product_id"\s*:\s*(\d+)'
|
|
id_match = re.search(id_pattern, ai_content)
|
|
if id_match:
|
|
try:
|
|
product_id = int(id_match.group(1))
|
|
product = self.env['product.product'].browse(product_id)
|
|
if product.exists():
|
|
_logger.info(f"AI found product match using regex: {product.name} (ID: {product.id})")
|
|
return product, 0.8 # Assume reasonable confidence
|
|
except (ValueError, Exception) as e:
|
|
_logger.error(f"Error extracting product ID: {e}")
|
|
|
|
# Look for product mentions in the text as a final fallback
|
|
product_mention_pattern = r'product\s+(?:id|ID|Id)\s*[:#]?\s*(\d+)'
|
|
mention_match = re.search(product_mention_pattern, ai_content)
|
|
if mention_match:
|
|
try:
|
|
product_id = int(mention_match.group(1))
|
|
product = self.env['product.product'].browse(product_id)
|
|
if product.exists():
|
|
_logger.info(f"AI found product match from text mention: {product.name} (ID: {product.id})")
|
|
return product, 0.75 # Lower confidence for this method
|
|
except (ValueError, Exception) as e:
|
|
_logger.error(f"Error extracting product ID from mention: {e}")
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error using AI for product matching: {e}")
|
|
|
|
return False, 0
|
|
|
|
def _create_product_order_line(self, product_name, quantity, description=""):
|
|
"""Create a sale order line for a product
|
|
|
|
This method attempts to find a matching product in the database using both AI-powered
|
|
matching and traditional matching techniques. It follows this process:
|
|
1. Try AI-powered matching with the OpenWebUI client
|
|
2. If AI matching fails or has low confidence, fall back to traditional matching:
|
|
- Look for exact matches on product name or part number
|
|
- Try partial matches with sufficient similarity
|
|
- Extract and match potential part numbers
|
|
3. If no product is found, return False
|
|
|
|
Args:
|
|
product_name (str): The name of the product to find
|
|
quantity (float): The quantity for the order line
|
|
description (str, optional): Additional description for the order line
|
|
|
|
Returns:
|
|
dict: Order line values if a product is found, False otherwise
|
|
"""
|
|
if not product_name:
|
|
return False
|
|
|
|
_logger.info(f"Attempting to find product match for: {product_name}")
|
|
|
|
# First, try using AI to find a product match
|
|
product_info = {
|
|
'name': product_name,
|
|
'quantity': quantity,
|
|
'description': description
|
|
}
|
|
|
|
# Try AI matching with increasing confidence thresholds
|
|
# This allows us to prefer high-confidence matches but fall back to lower confidence
|
|
# if no high-confidence match is found
|
|
confidence_thresholds = [0.9, 0.8, 0.7]
|
|
|
|
for threshold in confidence_thresholds:
|
|
product, confidence = self._ai_find_product_match(product_info)
|
|
if product and confidence >= threshold:
|
|
_logger.info(f"Using AI-matched product: {product.name} (ID: {product.id}) with confidence {confidence}")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# If AI matching failed or had low confidence, fall back to traditional matching
|
|
_logger.info("AI matching failed or had sufficient confidence, falling back to traditional matching")
|
|
|
|
# First, check if there's a part number in brackets like [ABC-123]
|
|
bracketed_part_number = None
|
|
bracket_match = re.search(r'\[(.*?)\]', product_name)
|
|
if bracket_match:
|
|
bracketed_part_number = bracket_match.group(1).strip()
|
|
_logger.info(f"Found bracketed part number: {bracketed_part_number}")
|
|
|
|
# Try exact match on the bracketed part number first
|
|
product = self.env['product.product'].search([
|
|
('default_code', '=', bracketed_part_number),
|
|
('sale_ok', '=', True)
|
|
], limit=1)
|
|
|
|
if product:
|
|
_logger.info(f"Found exact match for bracketed part number: {bracketed_part_number} -> {product.name} (ID: {product.id})")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# If no exact match, try case-insensitive match
|
|
product = self.env['product.product'].search([
|
|
('default_code', '=ilike', bracketed_part_number),
|
|
('sale_ok', '=', True)
|
|
], limit=1)
|
|
|
|
if product:
|
|
_logger.info(f"Found case-insensitive match for bracketed part number: {bracketed_part_number} -> {product.name} (ID: {product.id})")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# Search for matching product - try exact match on full name
|
|
product = self.env['product.product'].search([
|
|
('name', '=', product_name),
|
|
('sale_ok', '=', True)
|
|
], limit=1)
|
|
|
|
if product:
|
|
_logger.info(f"Found exact name match: {product_name} -> {product.name} (ID: {product.id})")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# If bracketed part number exists but didn't match, try to extract the product name without brackets
|
|
clean_product_name = product_name
|
|
if bracket_match:
|
|
clean_product_name = product_name.replace(f"[{bracketed_part_number}]", "").strip()
|
|
_logger.info(f"Trying with clean product name (without brackets): {clean_product_name}")
|
|
|
|
# Try exact match with clean name
|
|
product = self.env['product.product'].search([
|
|
('name', '=', clean_product_name),
|
|
('sale_ok', '=', True)
|
|
], limit=1)
|
|
|
|
if product:
|
|
_logger.info(f"Found exact match with clean name: {clean_product_name} -> {product.name} (ID: {product.id})")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# Try partial name match but only if name is substantial (to avoid false positives)
|
|
if len(clean_product_name) > 8: # Only try partial match if product name is substantial
|
|
_logger.info(f"Trying partial name match for: {clean_product_name}")
|
|
product = self.env['product.product'].search([
|
|
('name', 'ilike', clean_product_name),
|
|
('sale_ok', '=', True)
|
|
], limit=1)
|
|
|
|
if product:
|
|
# Verify this isn't just a trivial match
|
|
if len(product.name) > 5 and (clean_product_name.lower() in product.name.lower() or
|
|
product.name.lower() in clean_product_name.lower()):
|
|
_logger.info(f"Found partial name match: {clean_product_name} -> {product.name} (ID: {product.id})")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# If still no product found, try extracting potential part numbers
|
|
if any(c.isdigit() for c in product_name): # Check if product name contains numbers
|
|
_logger.info(f"Trying to extract part numbers from: {product_name}")
|
|
|
|
# Start with bracketed part number if available
|
|
part_numbers = [bracketed_part_number] if bracketed_part_number else []
|
|
|
|
# Extract additional part numbers using patterns
|
|
part_number_patterns = [
|
|
# Model numbers like ABC-123, ABC123, etc.
|
|
r'[A-Z][A-Z0-9\-]{3,}',
|
|
# Part numbers with specific formats (e.g., CPGPD-20N000BEE)
|
|
r'[A-Z]+-[A-Z0-9]+',
|
|
# Product codes with specific prefixes (e.g., PS-0600-4L)
|
|
r'[A-Z]+-[0-9]+-[A-Z0-9]+'
|
|
]
|
|
|
|
for pattern in part_number_patterns:
|
|
matches = re.findall(pattern, product_name, re.IGNORECASE)
|
|
if matches:
|
|
part_numbers.extend([m for m in matches if m != bracketed_part_number])
|
|
|
|
# Filter out duplicates and short part numbers
|
|
part_numbers = [p for p in part_numbers if p and len(p) >= 4]
|
|
|
|
if part_numbers:
|
|
_logger.info(f"Extracted potential part numbers: {part_numbers}")
|
|
|
|
for part in part_numbers:
|
|
# Try exact match on part number
|
|
product = self.env['product.product'].search([
|
|
('default_code', '=', part),
|
|
('sale_ok', '=', True)
|
|
], limit=1)
|
|
|
|
if product:
|
|
_logger.info(f"Found exact match for part number: {part} -> {product.name} (ID: {product.id})")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# Try case-insensitive match
|
|
product = self.env['product.product'].search([
|
|
('default_code', '=ilike', part),
|
|
('sale_ok', '=', True)
|
|
], limit=1)
|
|
|
|
if product:
|
|
_logger.info(f"Found case-insensitive match for part number: {part} -> {product.name} (ID: {product.id})")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# Only try partial match for longer part numbers (to avoid false positives)
|
|
if len(part) >= 6:
|
|
product = self.env['product.product'].search([
|
|
('default_code', 'ilike', part),
|
|
('sale_ok', '=', True)
|
|
], limit=1)
|
|
|
|
if product and product.default_code:
|
|
# Verify this isn't just a trivial match by checking substantial overlap
|
|
if len(product.default_code) >= 4 and \
|
|
(part.lower() in product.default_code.lower() or \
|
|
product.default_code.lower() in part.lower()):
|
|
_logger.info(f"Found partial match for part number: {part} -> {product.default_code} ({product.name}, ID: {product.id})")
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
# If no product found, log it and return False
|
|
if not product:
|
|
_logger.info(f"No matching product found for: {product_name}")
|
|
return False
|
|
|
|
return self._prepare_order_line_values(product, quantity, description)
|
|
|
|
def _prepare_order_line_values(self, product, quantity, description=""):
|
|
"""Prepare values for creating a sale order line"""
|
|
# 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)
|
|
|