diff --git a/helpdesk_sale_order_ai/__init__.py b/helpdesk_sale_order_ai/__init__.py
index 5e0a42f..b9be172 100644
--- a/helpdesk_sale_order_ai/__init__.py
+++ b/helpdesk_sale_order_ai/__init__.py
@@ -28,7 +28,8 @@ def post_init_hook(cr, registry=None):
return
_logger.info("Calling _ensure_default_template")
- env['openwebui.prompt.template']._ensure_default_template('helpdesk')
+ # Use our bridge model which will call the correct underlying model
+ env['ai.openwebui.prompt.template']._ensure_default_template('helpdesk')
_logger.info("Default template ensured successfully")
_logger.info("=== END: post_init_hook ====")
except Exception as e:
diff --git a/helpdesk_sale_order_ai/__manifest__.py b/helpdesk_sale_order_ai/__manifest__.py
index 946ff7c..a485672 100644
--- a/helpdesk_sale_order_ai/__manifest__.py
+++ b/helpdesk_sale_order_ai/__manifest__.py
@@ -16,12 +16,13 @@
'maintainer': 'it@bemade.org',
'depends': [
'helpdesk_sale_order',
- 'openwebui_connector',
+ 'openwebui_base',
],
'data': [
'security/ir.model.access.csv',
'views/helpdesk_team_views.xml',
'views/helpdesk_ticket_views.xml',
+ 'views/helpdesk_ticket_button_views.xml',
'views/res_config_settings_views.xml',
'views/ai_prompt_template_views.xml',
],
diff --git a/helpdesk_sale_order_ai/data/config.json b/helpdesk_sale_order_ai/data/config.json
deleted file mode 100644
index a4d56ae..0000000
--- a/helpdesk_sale_order_ai/data/config.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "OpenWebUI": {
- "api_key": "",
- "base_url": "https://ai.bemade.org/api",
- "model": "anthropic.claude-3-7-sonnet-latest"
- }
-}
diff --git a/helpdesk_sale_order_ai/migrations/1.0/__init__.py b/helpdesk_sale_order_ai/migrations/1.0/__init__.py
deleted file mode 100644
index 7af1d05..0000000
--- a/helpdesk_sale_order_ai/migrations/1.0/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# -*- coding: utf-8 -*-
-# This file is intentionally left empty to make the directory a proper Python package
diff --git a/helpdesk_sale_order_ai/migrations/1.0/post-migration.py b/helpdesk_sale_order_ai/migrations/1.0/post-migration.py
deleted file mode 100644
index cd81831..0000000
--- a/helpdesk_sale_order_ai/migrations/1.0/post-migration.py
+++ /dev/null
@@ -1,104 +0,0 @@
-# -*- coding: utf-8 -*-
-# This migration script will run after the module update
-# It will ensure the prompt templates are properly set up
-
-import logging
-_logger = logging.getLogger(__name__)
-
-def migrate(cr, version):
- """
- Ensure prompt templates are properly set up after migration
- """
- # Check if we have any prompt templates
- cr.execute("""
- SELECT COUNT(*) FROM helpdesk_ai_prompt_template
- """)
-
- template_count = cr.fetchone()[0]
-
- if template_count == 0:
- # Create a default template if none exists
- _logger.info("No prompt templates found, creating default template")
-
- default_template_name = "Default Sales Order Template"
- default_template_content = """
-You are a sales order assistant. Your task is to analyze the customer's request and suggest products or services that would meet their needs.
-
-Please provide a list of products or services in the following format:
-1. Product Name | Quantity | Description
-2. Product Name | Quantity | Description
-
-If you're not sure about a specific product, suggest a generic service item with a description of what it should accomplish.
-"""
-
- # Insert the default template
- cr.execute("""
- INSERT INTO helpdesk_ai_prompt_template (name, content, create_date, write_date)
- VALUES (%s, %s, now(), now())
- RETURNING id
- """, (default_template_name, default_template_content))
-
- template_id = cr.fetchone()[0]
-
- # Set this as the default template
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value, create_date, write_date)
- VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
- ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
- """, (str(template_id),))
-
- # Log the migration
- cr.execute("""
- INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
- VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
- 'Post-migration: Created default prompt template with ID ' || %s,
- '/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 50, 'migrate')
- """, (str(template_id),))
- else:
- # Check if we have a default template set
- cr.execute("""
- SELECT value FROM ir_config_parameter
- WHERE key = 'helpdesk_sale_order_ai.default_prompt_template_id'
- """)
-
- default_template_id = cr.fetchone()
-
- if not default_template_id:
- # Get the first template and set it as default
- cr.execute("""
- SELECT id FROM helpdesk_ai_prompt_template
- ORDER BY id ASC
- LIMIT 1
- """)
-
- template_id = cr.fetchone()[0]
-
- # Set this as the default template
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value, create_date, write_date)
- VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
- ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
- """, (str(template_id),))
-
- # Log the migration
- cr.execute("""
- INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
- VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
- 'Post-migration: Set existing template with ID ' || %s || ' as default',
- '/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 75, 'migrate')
- """, (str(template_id),))
- else:
- _logger.info(f"Default prompt template already set: {default_template_id[0]}")
-
- # Ensure the system parameters are properly set up
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value, create_date, write_date)
- VALUES ('openai.base_url', 'https://ai.bemade.org/api', now(), now())
- ON CONFLICT (key) DO NOTHING
- """)
-
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value, create_date, write_date)
- VALUES ('openai.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
- ON CONFLICT (key) DO NOTHING
- """)
diff --git a/helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py b/helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py
deleted file mode 100644
index 39d3f45..0000000
--- a/helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py
+++ /dev/null
@@ -1,111 +0,0 @@
-# -*- coding: utf-8 -*-
-import logging
-
-_logger = logging.getLogger(__name__)
-
-def migrate(cr, version):
- """
- Migrate helpdesk.ai.prompt.template records to openwebui.prompt.template
- """
- if not version:
- return
-
- _logger.info("Starting migration of AI prompt templates")
-
- # Check if the old model exists
- cr.execute("SELECT 1 FROM ir_model WHERE model = 'helpdesk.ai.prompt.template'")
- if not cr.fetchone():
- _logger.info("No helpdesk.ai.prompt.template model found, skipping migration")
- return
-
- # Check if the new model exists
- cr.execute("SELECT 1 FROM ir_model WHERE model = 'openwebui.prompt.template'")
- if not cr.fetchone():
- _logger.warning("openwebui.prompt.template model not found, cannot migrate")
- return
-
- # Get all templates from the old model
- cr.execute("""
- SELECT id, name, content, is_default, active, sequence
- FROM helpdesk_ai_prompt_template
- """)
- old_templates = cr.fetchall()
-
- if not old_templates:
- _logger.info("No templates found in helpdesk.ai.prompt.template, skipping migration")
- return
-
- _logger.info(f"Found {len(old_templates)} templates to migrate")
-
- # For each template, create a corresponding record in the new model
- for template_id, name, content, is_default, active, sequence in old_templates:
- # Check if a template with the same name already exists in the new model
- cr.execute("""
- SELECT id FROM openwebui_prompt_template
- WHERE name = %s AND template_type = 'helpdesk'
- """, (name,))
- existing = cr.fetchone()
-
- if existing:
- _logger.info(f"Template '{name}' already exists in openwebui.prompt.template, updating")
- cr.execute("""
- UPDATE openwebui_prompt_template
- SET content = %s, is_default = %s, active = %s, sequence = %s
- WHERE id = %s
- """, (content, is_default, active, sequence, existing[0]))
- else:
- _logger.info(f"Creating new template '{name}' in openwebui.prompt.template")
- cr.execute("""
- INSERT INTO openwebui_prompt_template
- (name, content, is_default, active, sequence, template_type, create_date, write_date)
- VALUES (%s, %s, %s, %s, %s, 'helpdesk', now(), now())
- """, (name, content, is_default, active, sequence))
-
- # Update system parameters that reference the old model
- cr.execute("""
- SELECT key, value FROM ir_config_parameter
- WHERE key LIKE 'helpdesk_sale_order_ai.%_prompt_template_id'
- """)
- params = cr.fetchall()
-
- for key, value in params:
- if value and value.isdigit():
- # Get the name of the template
- cr.execute("""
- SELECT name FROM helpdesk_ai_prompt_template
- WHERE id = %s
- """, (int(value),))
- template_name = cr.fetchone()
-
- if template_name:
- # Find the corresponding template in the new model
- cr.execute("""
- SELECT id FROM openwebui_prompt_template
- WHERE name = %s AND template_type = 'helpdesk'
- """, (template_name[0],))
- new_template_id = cr.fetchone()
-
- if new_template_id:
- new_key = key.replace('helpdesk_sale_order_ai', 'openwebui_connector')
- _logger.info(f"Updating system parameter {key} to {new_key} with value {new_template_id[0]}")
-
- # Check if the new parameter already exists
- cr.execute("""
- SELECT id FROM ir_config_parameter
- WHERE key = %s
- """, (new_key,))
- existing_param = cr.fetchone()
-
- if existing_param:
- cr.execute("""
- UPDATE ir_config_parameter
- SET value = %s
- WHERE id = %s
- """, (str(new_template_id[0]), existing_param[0]))
- else:
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value)
- VALUES (%s, %s)
- """, (new_key, str(new_template_id[0])))
-
- _logger.info("Migration of AI prompt templates completed")
diff --git a/helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py b/helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py
deleted file mode 100644
index ed0ca44..0000000
--- a/helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# -*- coding: utf-8 -*-
-import logging
-
-_logger = logging.getLogger(__name__)
-
-def migrate(cr, version):
- """
- Migrate system parameters from helpdesk OpenWebUI client to centralized OpenAI client
- """
- if not version:
- return
-
- _logger.info("Starting migration of OpenWebUI configuration parameters")
-
- # Map of old parameter keys to new parameter keys
- param_mapping = {
- 'openwebui.api_key': 'openai.api_key',
- 'openwebui.base_url': 'openai.base_url',
- 'openwebui.model': 'openai.model',
- }
-
- # For each parameter, check if it exists and migrate it if needed
- for old_key, new_key in param_mapping.items():
- # Check if the old parameter exists
- cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (old_key,))
- old_value = cr.fetchone()
-
- if old_value and old_value[0]:
- # Check if the new parameter already exists
- cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (new_key,))
- new_value = cr.fetchone()
-
- if not new_value or not new_value[0]:
- # New parameter doesn't exist or is empty, set it to the old value
- _logger.info(f"Migrating parameter {old_key} to {new_key} with value {old_value[0]}")
-
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value)
- VALUES (%s, %s)
- ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
- """, (new_key, old_value[0]))
-
- _logger.info("Migration of OpenWebUI configuration parameters completed")
diff --git a/helpdesk_sale_order_ai/migrations/__init__.py b/helpdesk_sale_order_ai/migrations/__init__.py
deleted file mode 100644
index 7af1d05..0000000
--- a/helpdesk_sale_order_ai/migrations/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# -*- coding: utf-8 -*-
-# This file is intentionally left empty to make the directory a proper Python package
diff --git a/helpdesk_sale_order_ai/models/__init__.py b/helpdesk_sale_order_ai/models/__init__.py
index 5611a47..f33fcff 100644
--- a/helpdesk_sale_order_ai/models/__init__.py
+++ b/helpdesk_sale_order_ai/models/__init__.py
@@ -3,9 +3,9 @@
from . import helpdesk_ticket
from . import helpdesk_team
from . import sale_order
-# Using centralized openwebui.openwebui.client model from openwebui_connector
+from . import ai_openwebui_client # Bridge model for OpenWebUI client
+from . import ai_openwebui_prompt_template # Bridge model for OpenWebUI prompt templates
from . import res_config_settings
-# Using centralized openwebui.prompt.template model from openwebui_connector
# Import views after all models are loaded
from .. import views
diff --git a/helpdesk_sale_order_ai/models/ai_openwebui_client.py b/helpdesk_sale_order_ai/models/ai_openwebui_client.py
new file mode 100644
index 0000000..0505c3e
--- /dev/null
+++ b/helpdesk_sale_order_ai/models/ai_openwebui_client.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api, _
+import logging
+import requests
+import json
+
+_logger = logging.getLogger(__name__)
+
+class AIOpenWebUIClient(models.AbstractModel):
+ """
+ Abstract model to provide a bridge between the helpdesk_sale_order_ai module
+ and the openwebui_base module.
+
+ This ensures proper integration with the OpenWebUI API using configuration
+ from the settings.
+ """
+ _name = "helpdesk_sale_order_ai.openwebui.client"
+ _description = "OpenWebUI Client"
+
+ @api.model
+ def chat_completion(self, messages, model=None, **kwargs):
+ """
+ Send a chat completion request to the OpenWebUI API
+
+ Args:
+ messages: List of message dictionaries with 'role' and 'content'
+ model: The model to use for the completion (default: anthropic.claude-3-7-sonnet-latest)
+ **kwargs: Additional parameters to pass to the OpenWebUI client
+
+ Returns:
+ The response from the OpenWebUI API
+ """
+ # Check if openwebui_base module is installed
+ module_obj = self.env['ir.module.module']
+ openwebui_base_module = module_obj.search([('name', '=', 'openwebui_base'), ('state', '=', 'installed')])
+
+ if not openwebui_base_module:
+ _logger.error("The openwebui_base module is not installed. Cannot use AI features.")
+ return False
+
+ try:
+ # Get configuration from settings
+ config = self.env['ir.config_parameter'].sudo()
+ company = self.env.company
+
+ # Get provider configuration
+ provider = company.openwebui_provider_id
+ if not provider:
+ _logger.error("No OpenWebUI provider configured for this company")
+ return False
+
+ # Get API key and base URL from provider
+ api_key = provider.api_key
+ base_url = provider.base_url
+
+ if not api_key or not base_url:
+ _logger.error("Missing API key or base URL in OpenWebUI provider configuration")
+ return False
+
+ # Get model from company settings or use provided model or default
+ if not model and company.openwebui_default_model_id:
+ model = company.openwebui_default_model_id.technical_name
+
+ _logger.info(f"Using OpenWebUI API with model={model} and {len(messages)} messages")
+
+ # Log first few characters of the prompt for debugging
+ if messages and len(messages) > 0:
+ first_content = messages[0].get('content', '')[:100]
+ _logger.info(f"First message content (truncated): {first_content}...")
+
+ # Prepare the request
+ headers = {
+ 'Content-Type': 'application/json',
+ 'Authorization': f'Bearer {api_key}'
+ }
+
+ data = {
+ 'model': model,
+ 'messages': messages,
+ **kwargs
+ }
+
+ # Make the API call
+ endpoint = f"{base_url.rstrip('/')}/chat/completions"
+ _logger.info(f"Sending request to {endpoint}")
+
+ response = requests.post(
+ endpoint,
+ headers=headers,
+ json=data,
+ timeout=60 # Reasonable timeout for AI responses
+ )
+
+ # Check for successful response
+ response.raise_for_status()
+ result = response.json()
+
+ # Log response summary
+ if result:
+ _logger.info(f"Received response from OpenWebUI API: {str(result)[:200]}...")
+ else:
+ _logger.warning("Received empty response from OpenWebUI API")
+
+ return result
+
+ except requests.exceptions.RequestException as e:
+ _logger.error(f"API request error in chat_completion: {str(e)}")
+ return False
+ except json.JSONDecodeError as e:
+ _logger.error(f"JSON decode error in chat_completion: {str(e)}")
+ return False
+ except Exception as e:
+ _logger.error(f"Unexpected error in chat_completion: {str(e)}")
+ return False
diff --git a/helpdesk_sale_order_ai/models/ai_openwebui_prompt_template.py b/helpdesk_sale_order_ai/models/ai_openwebui_prompt_template.py
new file mode 100644
index 0000000..a746539
--- /dev/null
+++ b/helpdesk_sale_order_ai/models/ai_openwebui_prompt_template.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api, _
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class AIOpenWebUIPromptTemplate(models.AbstractModel):
+ """
+ Abstract model to provide a bridge between the old ai.openwebui.prompt.template model
+ and the new openwebui.prompt.template model from the openwebui_base module.
+
+ This ensures backward compatibility while leveraging the new centralized infrastructure.
+ """
+ _name = "ai.openwebui.prompt.template"
+ _description = "OpenWebUI Prompt Template Bridge"
+
+ @api.model
+ def _ensure_default_template(self, template_type='helpdesk'):
+ """Ensure that a default template exists for the given type"""
+ return self.env['openwebui.prompt.template']._ensure_default_template(template_type)
+
+ @api.model
+ def get_default_template(self, template_type='helpdesk'):
+ """Get the default template for the given type"""
+ return self.env['openwebui.prompt.template'].get_default_template(template_type)
diff --git a/helpdesk_sale_order_ai/models/ai_prompt_template.py b/helpdesk_sale_order_ai/models/ai_prompt_template.py
deleted file mode 100644
index c845c04..0000000
--- a/helpdesk_sale_order_ai/models/ai_prompt_template.py
+++ /dev/null
@@ -1,115 +0,0 @@
-# -*- coding: utf-8 -*-
-from odoo import models, fields, api, _
-
-
-class AIPromptTemplate(models.Model):
- _name = 'helpdesk.ai.prompt.template'
- _description = 'AI Prompt Template'
- _rec_name = 'name'
- _order = 'sequence, id'
-
- name = fields.Char(
- string='Name',
- required=True,
- )
- sequence = fields.Integer(
- string='Sequence',
- default=10,
- )
- active = fields.Boolean(
- string='Active',
- default=True,
- )
- is_default = fields.Boolean(
- string='Is Default',
- default=False,
- )
- content = fields.Text(
- string='Template Content',
- required=True,
- help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
- )
-
- @api.model
- def _ensure_default_template(self):
- """Ensure there's at least one default template"""
- import logging
- _logger = logging.getLogger(__name__)
- _logger.info("=== START: _ensure_default_template ====")
-
- try:
- _logger.info("Searching for default template")
- default_template = self.search([('is_default', '=', True)], limit=1)
- _logger.info(f"Default template search result: {default_template}")
-
- if not default_template:
- _logger.info("No default template found, creating one")
- # Create a default template if none exists
- default_content = """Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
-
-Ticket Description: {description}
-Customer: {customer}
-
-Please provide a list of products/services with quantities and descriptions in the following format:
-Product/Service Name | Quantity | Description
-"""
- _logger.info("Creating default template")
- new_template = self.create({
- 'name': 'Default Template',
- 'content': default_content,
- 'is_default': True,
- })
- _logger.info(f"Created default template: {new_template}")
- else:
- _logger.info(f"Found existing default template: {default_template.name}")
-
- _logger.info("=== END: _ensure_default_template ====")
- return True
- except Exception as e:
- _logger.error(f"ERROR in _ensure_default_template: {str(e)}")
- _logger.exception("Exception traceback:")
- raise
-
- @api.model_create_multi
- def create(self, vals_list):
- """Override create to handle default templates"""
- records = super(AIPromptTemplate, self).create(vals_list)
- # If any new template is set as default, unset default flag on others
- default_templates = records.filtered(lambda r: r.is_default)
- if default_templates:
- self.search([('id', 'not in', default_templates.ids), ('is_default', '=', True)]).write({'is_default': False})
- return records
-
- @api.model
- def get_default_template(self):
- """Get the default template"""
- import logging
- _logger = logging.getLogger(__name__)
- _logger.info("=== START: get_default_template ====")
-
- try:
- _logger.info("Searching for default template")
- default_template = self.search([('is_default', '=', True)], limit=1)
- _logger.info(f"Default template search result: {default_template}")
-
- if not default_template:
- _logger.info("No default template found, ensuring one exists")
- self._ensure_default_template()
- default_template = self.search([('is_default', '=', True)], limit=1)
- _logger.info(f"Default template after ensure: {default_template}")
-
- _logger.info("=== END: get_default_template ====")
- return default_template
- except Exception as e:
- _logger.error(f"ERROR in get_default_template: {str(e)}")
- _logger.exception("Exception traceback:")
- return self.browse()
-
- def set_as_default(self):
- """Set this template as the default"""
- if self:
- # Clear default flag on all other templates
- self.search([('id', '!=', self.id)]).write({'is_default': False})
- # Set this template as default
- self.write({'is_default': True})
- return True
diff --git a/helpdesk_sale_order_ai/models/helpdesk_team.py b/helpdesk_sale_order_ai/models/helpdesk_team.py
index 918ed3a..e6aaf64 100644
--- a/helpdesk_sale_order_ai/models/helpdesk_team.py
+++ b/helpdesk_sale_order_ai/models/helpdesk_team.py
@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
+import logging
+
+_logger = logging.getLogger(__name__)
class HelpdeskTeam(models.Model):
@@ -25,40 +28,40 @@ class HelpdeskTeam(models.Model):
if self.use_ai_sale_orders:
return True
- # Otherwise, check global setting
- param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'False')
+ # Otherwise, check global setting (default to True)
+ param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'True')
return param_value.lower() == 'true' if isinstance(param_value, str) else bool(param_value)
def _get_ai_prompt_template(self):
"""Get the AI prompt template to use for this team"""
self.ensure_one()
- # Ensure template model is initialized
- self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
-
- # If team has a specific template, use it
- if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
- return self.ai_prompt_template_id.content
+ try:
+ # Ensure template model is initialized
+ self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
- # Otherwise, get the global default template
- IrConfigParam = self.env['ir.config_parameter'].sudo()
- template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
-
- if template_id:
- try:
- template = self.env['openwebui.prompt.template'].browse(int(template_id))
- if template.exists():
- return template.content
- except (ValueError, TypeError):
- pass
+ # If team has a specific template, use it
+ if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
+ _logger.info(f"Using team-specific prompt template: {self.ai_prompt_template_id.name}")
+ return self.ai_prompt_template_id.content
- # Fallback to default template
- default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
- return default_template.content if default_template else """
+ # Otherwise, get the global default template
+ default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
+ if default_template:
+ _logger.info(f"Using default helpdesk prompt template: {default_template.name}")
+ return default_template.content
+ except Exception as e:
+ _logger.error(f"Error retrieving prompt template: {str(e)}")
+
+ # Fallback to hardcoded template if all else fails
+ _logger.warning("Using fallback hardcoded prompt template")
+ return """
Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
Ticket Description: {description}
- Customer: {customer}
+ Chatter Messages: {chatter_messages}
+ Attachments: {attachments_info}
+ Attachment Contents: {attachment_contents}
Please provide a list of products/services with quantities and descriptions in the following format:
Product/Service Name | Quantity | Description
diff --git a/helpdesk_sale_order_ai/models/helpdesk_ticket.py b/helpdesk_sale_order_ai/models/helpdesk_ticket.py
index 482d861..da2b2bd 100644
--- a/helpdesk_sale_order_ai/models/helpdesk_ticket.py
+++ b/helpdesk_sale_order_ai/models/helpdesk_ticket.py
@@ -4,9 +4,14 @@ from odoo.exceptions import UserError
import logging
import json
import re
+import shutil
+from pathlib import Path
_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'
@@ -46,157 +51,31 @@ class HelpdeskTicket(models.Model):
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
- _logger.info("Starting AI conversion to sale order for ticket %s", self.id)
+ # Add debug logging
+ _logger.debug(f"Sale order values before create: {values}")
+ _logger.debug(f"date_order type: {type(values.get('date_order'))}")
- # 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}")
+ # 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()
+ _logger.debug(f"Setting default date_order: {values['date_order']}")
+ # 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
+ _logger.debug(f"Converting empty {field} to None")
+
# 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
+ sale_order = self.env['sale.order'].create(values)
+
return {
'type': 'ir.actions.act_window',
'name': _('Sale Order'),
@@ -205,476 +84,278 @@ class HelpdeskTicket(models.Model):
'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"""
+
+ 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 using the helper methods.
+
+ Returns:
+ dict: Values for creating a sales order including order lines
+ """
self.ensure_one()
+ _logger.info(f"Generating AI sales order values for ticket {self.id}")
- _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.get('ticket_description', '')
+ chatter_messages = ticket_data.get('ticket_messages', '')
+ attachments_info = ticket_data.get('attachments_info', '')
+ attachment_contents = ticket_data.get('attachment_contents', '')
- # Get the ticket description
- description = self.description or ""
+ # Log the content being analyzed
+ _logger.info(f"AI Analysis - Content lengths: Description={len(description)}, Chatter={len(chatter_messages)}, Attachments={len(attachment_contents)}")
- # If description is empty, try to use the name
- if not description.strip():
- description = self.name or ""
+ # Get the OpenWebUI provider from company settings
+ company = self.env.company
+ provider = company.openwebui_provider_id
- # 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"
+ 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."
+ )
# 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
+ attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)])
+ attachments_list = []
+
+ # Process PDF files for analysis
+ 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"""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.
+ 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:
+ Chatter Messages (IMPORTANT - CAREFULLY ANALYZE THESE FOR PRODUCT INFORMATION):
{chatter_messages}
Attachments Information:
{attachments_info}
- Attachment Contents:
+ Attachment Contents (CAREFULLY ANALYZE PDF CONTENTS FOR PRODUCT DETAILS):
{attachment_contents}
- Based on this information, please perform two tasks:
+ WORKFLOW - FOLLOW THESE STEPS EXACTLY:
+ 1. Identify all products mentioned in the content (product names, codes, references)
+ 2. For EACH product identified:
+ 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. Store the returned product ID (or note if not found)
+ 3. Extract order details (client reference, dates, notes)
+ 4. Construct the JSON response using the product IDs you obtained from tool calls
- 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")
+ RESPONSE FORMAT:
+ Your response MUST ONLY be a valid JSON object with the following structure:
- 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" }}
- ]
+ "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 description",
+ "product_uom_qty": 0.0
+ }}]
+ ]
}}
- ```
- 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.
+ CRITICAL RULES:
+ 1. You MUST use the provided tools for EVERY product mentioned - DO NOT SKIP THIS STEP
+ 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
+ 4. Include as much detail as possible for unmatched products
+ 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:
- # Get the OpenWebUI client
- client = self.env['openai.openwebui.client']
+ _logger.info("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."
+ },
+ {
+ "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.info(f"Received AI response for ticket {self.id}")
- # 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))
+ _logger.error(f"Error in AI request: {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': {}
+ _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)}")
+
+ # The response from chat_with_tools should contain the content directly
+ # If it's a dictionary, use it directly
+ if isinstance(response, dict):
+ _logger.info("AI returned dictionary response, using directly")
+ return response
+
+ # Handle string responses (extract JSON if possible)
+ if isinstance(response, str):
+ _logger.info("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])
+ _logger.info("Successfully extracted JSON from text response")
+ return response_data
+ elif response.strip().startswith('{') and response.strip().endswith('}'):
+ response_data = json.loads(response.strip())
+ _logger.info("Successfully parsed direct JSON from text response")
+ 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)}"
}
- if not self.ai_generated_products:
- _logger.warning("No AI generated products found for ticket %s", self.id)
- return result['order_lines']
+ 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.
- # Log the AI response for debugging
- _logger.info("Parsing AI product suggestions for ticket %s: %s", self.id, self.ai_generated_products[:300])
+ Returns:
+ dict: Dictionary containing ticket data for AI analysis
+ """
+ self.ensure_one()
+ _logger.info(f"Preparing AI prompt data for ticket {self.id}")
- # 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
+ result = {
+ 'ticket_description': '',
+ 'ticket_messages': '',
+ 'attachments_info': '',
+ 'attachment_contents': ''
+ }
- json_matches = re.findall(json_pattern1, self.ai_generated_products)
+ # Get ticket description
+ if self.description:
+ result['ticket_description'] = self.description
- 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)
+ # 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)")
- 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
+ # Extract content from PDFs
+ if attachment.mimetype == 'application/pdf':
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
+ # Create temporary file
+ temp_path = f"/tmp/{attachment.name}"
+ shutil.copy(attachment._full_path(attachment.store_fname), temp_path)
- # 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 ''
+ # For PDF extraction, we'll just note the PDF file is present
+ # The actual extraction will be handled by the AI service which has built-in PDF processing
+ attachment_contents.append(f"PDF file: {attachment.name} (will be processed by AI)")
- # 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)
+ # Note: If PDF text extraction is needed directly in Odoo, consider adding:
+ # - A dependency on pdf2text or PyPDF2 in the module manifest
+ # - Implementing the extraction logic here
+ except Exception as e:
+ _logger.error(f"Error extracting content from PDF {attachment.name}: {e}")
+
+ result['attachments_info'] = '\n'.join(attachment_infos)
+ result['attachment_contents'] = '\n\n'.join(attachment_contents)
- 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
+ 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,
@@ -689,4 +370,35 @@ class HelpdeskTicket(models.Model):
# We intentionally don't set price_unit here to let Odoo's standard mechanisms handle it
- return (0, 0, line_values)
+ return line_values
+
+ def _ai_find_product_id_by_name(self, product_name: str) -> int | None:
+ """Use AI to 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:
+ """Use AI to 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
+
\ No newline at end of file
diff --git a/helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak b/helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak
new file mode 100644
index 0000000..a1a582a
--- /dev/null
+++ b/helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak
@@ -0,0 +1,1629 @@
+# -*- 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 = "
"
+ missing_products_html += f"{product_name}"
+
+ # Always show quantity
+ missing_products_html += f" (QTY: {quantity})"
+
+ # Add description if available
+ if description:
+ missing_products_html += f" - {description}"
+
+ missing_products_html += "
"
+
+ missing_products_html += "
"
+ missing_products_html += "
Please add these products manually or create them in the system.
"
+ missing_products_html += "
"
+ missing_products_html += ""
+
+ # 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' ', '\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)
+
diff --git a/helpdesk_sale_order_ai/models/res_config_settings.py b/helpdesk_sale_order_ai/models/res_config_settings.py
index 296a0b5..da789bf 100644
--- a/helpdesk_sale_order_ai/models/res_config_settings.py
+++ b/helpdesk_sale_order_ai/models/res_config_settings.py
@@ -7,23 +7,12 @@ _logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
- # We no longer need to define the fields here as they are now defined in the openai_connector module
- # This ensures that the settings are always in sync between the two modules
-
- # Override the model field to use our selection method
- openai_model = fields.Selection(
- selection='_get_openwebui_models',
- string='AI Model',
- help='Model to use for AI API calls',
- default='anthropic.claude-3-7-sonnet-latest',
- config_parameter='openai.model',
- )
+ # Inherit the fields from openwebui_base module
+ # These fields are already defined in the openwebui_base module
# Add a field to select the prompt template directly
- # In Odoo 18, we need to ensure this field is properly defined
helpdesk_ai_prompt_template_id = fields.Many2one(
'openwebui.prompt.template',
- domain="[('template_type', '=', 'helpdesk')]",
string='Default AI Prompt Template',
help='Default template for the prompt sent to the AI.',
ondelete='set null', # This helps avoid constraint errors
@@ -55,37 +44,6 @@ class ResConfigSettings(models.TransientModel):
IrConfigParam = self.env['ir.config_parameter'].sudo()
if self.helpdesk_ai_prompt_template_id:
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.helpdesk_ai_prompt_template_id.id))
- else:
- # If no template is selected, ensure there's a default one
- self._set_default_values()
-
- @api.model
- def _get_openwebui_models(self):
- """Get available models from OpenWebUI API"""
- try:
- client = self.env['openwebui.client']
- models = client.get_available_models()
- return models
- except Exception as e:
- _logger.error(f"Error fetching OpenWebUI models: {e}")
- # Return default model on error
- default_model = 'anthropic.claude-3-7-sonnet-latest'
- return [(default_model, default_model)]
-
- @api.model
- def _set_default_values(self):
- """Set default values for configuration parameters"""
- # Ensure there's a default template
- template_model = self.env['openwebui.prompt.template']
- template_model._ensure_default_template()
- default_template = template_model.get_default_template()
-
- # Set the default template ID in config parameters if not set
- IrConfigParam = self.env['ir.config_parameter'].sudo()
- default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
- if not default_template_id and default_template:
- # Store as string to ensure compatibility with the OpenAI connector module
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
@api.model
def get_prompt_template(self):
@@ -96,10 +54,10 @@ class ResConfigSettings(models.TransientModel):
if template_id:
try:
# Make sure the template model exists first
- self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
+ self.env['ai.openwebui.prompt.template']._ensure_default_template('helpdesk')
# The template_id is already an integer in the database now
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
- template = self.env['openwebui.prompt.template'].browse(template_id_int)
+ template = self.env['ai.openwebui.prompt.template'].browse(template_id_int)
if template.exists():
return template.content
except (ValueError, TypeError) as e:
@@ -107,5 +65,5 @@ class ResConfigSettings(models.TransientModel):
pass
# Fallback to default template
- default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
+ default_template = self.env['ai.openwebui.prompt.template'].get_default_template('helpdesk')
return default_template.content if default_template else ""
diff --git a/helpdesk_sale_order_ai/models/sale_order.py b/helpdesk_sale_order_ai/models/sale_order.py
index 96eebf3..1a1c829 100644
--- a/helpdesk_sale_order_ai/models/sale_order.py
+++ b/helpdesk_sale_order_ai/models/sale_order.py
@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
+import logging
+
+_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
@@ -27,3 +30,56 @@ class SaleOrder(models.Model):
copy=False,
help='Helpdesk ticket from which this sale order was created'
)
+
+ def action_confirm(self):
+ """Override to delete missing products message when sale order is confirmed"""
+ # Call the original method first
+ result = super(SaleOrder, self).action_confirm()
+
+ # Delete missing products message if exists
+ if self.has_missing_products:
+ self._delete_missing_products_message()
+
+ # Reset missing products flags
+ self.write({
+ 'missing_product_count': 0,
+ 'has_missing_products': False
+ })
+
+ return result
+
+ def _delete_missing_products_message(self):
+ """Delete the missing products message from the chatter"""
+ # Find the message with the unique identifier
+ domain = [
+ ('res_id', '=', self.id),
+ ('model', '=', 'sale.order'),
+ ('body', 'ilike', '%%'),
+ ]
+
+ messages = self.env['mail.message'].sudo().search(domain)
+
+ if messages:
+ _logger.info(f"Found {len(messages)} missing products message(s) for sale order {self.id} with IDs: {messages.ids}")
+ try:
+ # Make sure we're actually deleting the message
+ message_ids = messages.ids # Store IDs before deletion
+ messages.sudo().unlink()
+ _logger.info(f"Successfully deleted missing products message(s) with IDs: {message_ids} for sale order {self.id}")
+ except Exception as e:
+ _logger.error(f"Error deleting missing products message: {e}")
+ else:
+ _logger.warning(f"No missing products message found for sale order {self.id} - check HTML comment marker")
+
+ # Debug: Try a broader search to see if there are any messages at all
+ all_messages = self.env['mail.message'].sudo().search([
+ ('res_id', '=', self.id),
+ ('model', '=', 'sale.order'),
+ ], limit=5)
+
+ if all_messages:
+ _logger.info(f"Found {len(all_messages)} recent messages for this sale order. First message body sample: {all_messages[0].body[:100] if all_messages[0].body else 'Empty'}...")
+ else:
+ _logger.info(f"No messages found at all for sale order {self.id}")
+
+
diff --git a/helpdesk_sale_order_ai/security/ir.model.access.csv b/helpdesk_sale_order_ai/security/ir.model.access.csv
index 1fb0b9c..ae19dcc 100644
--- a/helpdesk_sale_order_ai/security/ir.model.access.csv
+++ b/helpdesk_sale_order_ai/security/ir.model.access.csv
@@ -1,3 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
-access_openwebui_prompt_template_helpdesk_user,openwebui.prompt.template.helpdesk.user,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
-access_openwebui_prompt_template_helpdesk_manager,openwebui.prompt.template.helpdesk.manager,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1
+access_ai_openwebui_prompt_template_helpdesk_user,ai.openwebui.prompt.template.helpdesk.user,openwebui_base.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
+access_ai_openwebui_prompt_template_helpdesk_manager,ai.openwebui.prompt.template.helpdesk.manager,openwebui_base.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1
diff --git a/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml b/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml
index 8aee278..8ba34df 100644
--- a/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml
+++ b/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml
@@ -6,40 +6,19 @@
openwebui.prompt.template
diff --git a/helpdesk_sale_order_ai/wizards/__init__.py b/helpdesk_sale_order_ai/wizards/__init__.py
deleted file mode 100644
index 43d33cd..0000000
--- a/helpdesk_sale_order_ai/wizards/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from . import openwebui_config_wizard
diff --git a/openwebui_base/__init__.py b/openwebui_base/__init__.py
new file mode 100644
index 0000000..0650744
--- /dev/null
+++ b/openwebui_base/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/openwebui_base/__manifest__.py b/openwebui_base/__manifest__.py
new file mode 100644
index 0000000..4d58970
--- /dev/null
+++ b/openwebui_base/__manifest__.py
@@ -0,0 +1,30 @@
+# Bemade Inc.
+#
+# Copyright (C) 2023-June Bemade Inc. ().
+# Author: Marc Durepos (Contact : mdurepos@durpro.com)
+#
+# This program is under the terms of the GNU Lesser General Public License (LGPL-3)
+# For details, visit https://www.gnu.org/licenses/lgpl-3.0.en.html
+
+{
+ "name": "OpenWebUI Base",
+ "version": "18.0.0.1.0",
+ "license": "LGPL-3",
+ "category": "Tools",
+ "summary": "Base module for OpenWebUI integration",
+ "author": "Bemade Inc.",
+ "website": "https://www.bemade.org",
+ "depends": ["base", "base_setup"],
+ "external_dependencies": {
+ "python": ["openwebui-client>=0.3.0"],
+ },
+ "data": [
+ "security/ir.model.access.csv",
+ "views/openwebui_provider_views.xml",
+ "views/openwebui_model_views.xml",
+ "views/res_config_settings.xml",
+ ],
+ "installable": True,
+ "application": False,
+ "auto_install": False,
+}
diff --git a/openwebui_base/models/__init__.py b/openwebui_base/models/__init__.py
new file mode 100644
index 0000000..4745ae6
--- /dev/null
+++ b/openwebui_base/models/__init__.py
@@ -0,0 +1,5 @@
+from . import res_company
+from . import res_config_settings
+from . import openwebui_model
+from . import openwebui_provider
+from . import openwebui_prompt_template
diff --git a/openwebui_base/models/openwebui_model.py b/openwebui_base/models/openwebui_model.py
new file mode 100644
index 0000000..cf534b8
--- /dev/null
+++ b/openwebui_base/models/openwebui_model.py
@@ -0,0 +1,29 @@
+from odoo import models, fields, api
+
+
+class OpenWebUIModel(models.Model):
+ _name = "openwebui.model"
+ _description = "OpenWebUI Model"
+
+ technical_name = fields.Char(
+ required=True,
+ help="Technical name of the model on the OpenWebUI server.",
+ readonly=True,
+ )
+ name = fields.Char(
+ required=True,
+ help="User-friendly name of the model.",
+ readonly=True,
+ )
+ provider_id = fields.Many2one(
+ "openwebui.provider",
+ required=True,
+ )
+
+ _sql_constraints = [
+ (
+ "technical_name_provider_unique",
+ "unique(technical_name, provider_id)",
+ "Technical name must be unique per provider",
+ )
+ ]
diff --git a/openwebui_base/models/openwebui_prompt_template.py b/openwebui_base/models/openwebui_prompt_template.py
new file mode 100644
index 0000000..ad4eede
--- /dev/null
+++ b/openwebui_base/models/openwebui_prompt_template.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api, _
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class OpenWebUIPromptTemplate(models.Model):
+ _name = "openwebui.prompt.template"
+ _description = "OpenWebUI Prompt Template"
+
+ name = fields.Char(string="Name", required=True)
+ template_type = fields.Selection([
+ ('helpdesk', 'Helpdesk'),
+ ('general', 'General'),
+ ('custom', 'Custom'),
+ ], string="Template Type", default='general', required=True)
+ content = fields.Text(string="Template Content", required=True)
+ is_default = fields.Boolean(string="Is Default", default=False)
+
+ @api.model
+ def _ensure_default_template(self, template_type='helpdesk'):
+ """Ensure that a default template exists for the given type"""
+ default_template = self.search([
+ ('template_type', '=', template_type),
+ ('is_default', '=', True)
+ ], limit=1)
+
+ if not default_template:
+ # Create a default template based on the type
+ if template_type == 'helpdesk':
+ self.create({
+ 'name': 'Default Helpdesk Template',
+ 'template_type': 'helpdesk',
+ 'content': """You are a helpful assistant analyzing helpdesk tickets to create sales orders.
+
+IMPORTANT: In this system, product names are often formatted as the product reference number in square brackets followed by the product name (for example: [REF123] Widget). You can use the reference code inside the brackets to identify products in the database. If you are unsure, select the product with the highest matching reference code.
+
+Please analyze the following ticket information and extract:
+
+1. Client reference number (e.g., PO-12345, REF-678)
+2. Products or services needed with quantities and descriptions
+3. Any special requirements or notes
+4. Delivery date expectations
+5. Payment terms if mentioned
+
+Ticket Information:
+{ticket_description}
+
+{ticket_messages}
+
+Please format your response as a structured JSON with the following format:
+{
+ "sale_order_fields": {
+ "client_order_ref": "Reference number if found",
+ "commitment_date": "YYYY-MM-DD if found",
+ "payment_term_id": "Payment terms if found"
+ },
+ "order_lines": [
+ {
+ "product_name": "Product name or description",
+ "quantity": 1.0,
+ "notes": "Any special instructions for this line"
+ }
+ ]
+}""",
+ 'is_default': True
+ })
+ elif template_type == 'general':
+ self.create({
+ 'name': 'Default General Template',
+ 'template_type': 'general',
+ 'content': """You are a helpful assistant. Please provide a detailed and accurate response to the following query:
+
+{query}""",
+ 'is_default': True
+ })
+
+ @api.model
+ def get_default_template(self, template_type='helpdesk'):
+ """Get the default template for the given type"""
+ self._ensure_default_template(template_type)
+ return self.search([
+ ('template_type', '=', template_type),
+ ('is_default', '=', True)
+ ], limit=1)
diff --git a/openwebui_base/models/openwebui_provider.py b/openwebui_base/models/openwebui_provider.py
new file mode 100644
index 0000000..9d7a213
--- /dev/null
+++ b/openwebui_base/models/openwebui_provider.py
@@ -0,0 +1,66 @@
+from odoo import models, fields, api
+from .openwebui_model import OpenWebUIModel
+import openwebui_client
+from typing import Optional
+
+
+class OpenWebUIProvider(models.Model):
+ _name = "openwebui.provider"
+ _description = "OpenWebUI Provider"
+
+ name = fields.Char(string="Name", required=True)
+ base_url = fields.Char(
+ string="Base URL",
+ required=True,
+ help="Base URL of the OpenWebUI server.",
+ )
+ api_key = fields.Char(
+ string="API Key",
+ required=True,
+ help="API key for authentication.",
+ groups="base.group_system",
+ )
+ model_ids = fields.One2many(
+ comodel_name="openwebui.model",
+ inverse_name="provider_id",
+ )
+ default_model_id = fields.Many2one(
+ comodel_name="openwebui.model",
+ )
+
+ def get_client(
+ self, model: Optional[OpenWebUIModel] = None
+ ) -> openwebui_client.OpenWebUIClient:
+ if not self.base_url or not self.api_key:
+ raise ValueError("Base URL and API key are required")
+ model = model or self.default_model_id
+ return openwebui_client.OpenWebUIClient(
+ base_url=self.base_url,
+ api_key=self.api_key,
+ default_model=model.technical_name if model else None,
+ )
+
+ def sync_models(self):
+ client = self.get_client()
+ local_models = self.model_ids
+ remote_models = {model.id: model.name for model in client.models.list()}
+ for model in local_models:
+ if model.technical_name not in remote_models.keys():
+ model.unlink()
+ elif model.name != remote_models[model.technical_name]:
+ model.name = remote_models[model.technical_name]
+ for model_id, model_name in remote_models.items():
+ if model_id not in local_models.mapped("technical_name"):
+ self.env["openwebui.model"].create(
+ {
+ "name": model_name,
+ "technical_name": model_id,
+ "provider_id": self.id,
+ }
+ )
+
+ def create(self, vals_list):
+ providers = super().create(vals_list)
+ for provider in providers:
+ provider.sync_models()
+ return providers
diff --git a/openwebui_base/models/res_company.py b/openwebui_base/models/res_company.py
new file mode 100644
index 0000000..989cd52
--- /dev/null
+++ b/openwebui_base/models/res_company.py
@@ -0,0 +1,15 @@
+from odoo import models, fields
+
+
+class Company(models.Model):
+ _inherit = "res.company"
+
+ openwebui_provider_id = fields.Many2one(
+ "openwebui.provider",
+ string="OpenWebUI Provider",
+ )
+ openwebui_default_model_id = fields.Many2one(
+ "openwebui.model",
+ string="OpenWebUI Default Model",
+ domain=[("provider_id", "=", "openwebui_provider_id")],
+ )
diff --git a/openwebui_base/models/res_config_settings.py b/openwebui_base/models/res_config_settings.py
new file mode 100644
index 0000000..11de914
--- /dev/null
+++ b/openwebui_base/models/res_config_settings.py
@@ -0,0 +1,44 @@
+from odoo import models, fields, api
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = "res.config.settings"
+
+ openwebui_provider_id = fields.Many2one(
+ comodel_name="openwebui.provider",
+ related="company_id.openwebui_provider_id",
+ readonly=False,
+ company_dependent=True,
+ )
+
+ openwebui_default_model_id = fields.Many2one(
+ comodel_name="openwebui.model",
+ related="company_id.openwebui_default_model_id",
+ readonly=False,
+ company_dependent=True,
+ )
+
+ use_ai_sale_orders = fields.Boolean(
+ string='Use AI for Sale Orders',
+ help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions.',
+ )
+
+ @api.model
+ def get_values(self):
+ """Get values for the settings form"""
+ res = super(ResConfigSettings, self).get_values()
+
+ # Get the AI sale orders setting from the system parameter
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ param_value = IrConfigParam.get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'True')
+ res['use_ai_sale_orders'] = param_value.lower() == 'true' if isinstance(param_value, str) else bool(param_value)
+
+ return res
+
+ def set_values(self):
+ """Set values from the settings form"""
+ super(ResConfigSettings, self).set_values()
+
+ # Save the AI sale orders setting to the system parameter
+ IrConfigParam = self.env['ir.config_parameter'].sudo()
+ IrConfigParam.set_param('helpdesk_sale_order_ai.use_ai_sale_orders', str(self.use_ai_sale_orders))
diff --git a/openwebui_base/security/ir.model.access.csv b/openwebui_base/security/ir.model.access.csv
new file mode 100644
index 0000000..9f62981
--- /dev/null
+++ b/openwebui_base/security/ir.model.access.csv
@@ -0,0 +1,7 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_openwebui_provider_admin,openwebui.provider admin,model_openwebui_provider,base.group_system,1,1,1,1
+access_openwebui_provider_user,openwebui.provider user,model_openwebui_provider,base.group_user,1,0,0,0
+access_openwebui_model_admin,openwebui.model admin,model_openwebui_model,base.group_system,1,1,1,1
+access_openwebui_model_user,openwebui.model user,model_openwebui_model,base.group_user,1,0,0,0
+access_openwebui_prompt_template_admin,openwebui.prompt.template admin,model_openwebui_prompt_template,base.group_system,1,1,1,1
+access_openwebui_prompt_template_user,openwebui.prompt.template user,model_openwebui_prompt_template,base.group_user,1,0,0,0
diff --git a/openwebui_base/static/description/icon.png b/openwebui_base/static/description/icon.png
new file mode 100644
index 0000000..31bd447
Binary files /dev/null and b/openwebui_base/static/description/icon.png differ
diff --git a/openwebui_base/static/description/icon.svg b/openwebui_base/static/description/icon.svg
new file mode 100644
index 0000000..bea1e77
--- /dev/null
+++ b/openwebui_base/static/description/icon.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openwebui_base/tests/__init__.py b/openwebui_base/tests/__init__.py
new file mode 100644
index 0000000..1da068f
--- /dev/null
+++ b/openwebui_base/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_openwebui
diff --git a/openwebui_base/tests/test_openwebui.py b/openwebui_base/tests/test_openwebui.py
new file mode 100644
index 0000000..8451a95
--- /dev/null
+++ b/openwebui_base/tests/test_openwebui.py
@@ -0,0 +1,52 @@
+from odoo.tests import TransactionCase, Form
+import os
+
+
+class TestOpenWebUI(TransactionCase):
+ def setUp(self):
+ super().setUp()
+ self.provider = self.env["openwebui.provider"].create(
+ {
+ "name": "Test Provider",
+ "base_url": os.getenv("OPENWEBUI_BASE_URL"),
+ "api_key": os.getenv("OPENWEBUI_API_KEY"),
+ }
+ )
+
+ def test_sync_models(self):
+ self.assertTrue(self.provider.model_ids)
+ self.assertIn(
+ "MS.qwen3:32b-q8_0", self.provider.model_ids.mapped("technical_name")
+ )
+
+ def test_get_client(self):
+ client = self.provider.get_client()
+ self.assertTrue(client)
+
+ def test_settings(self):
+ model = self.provider.model_ids.filtered(
+ lambda model: model.technical_name == "MS.qwen3:32b-q8_0"
+ )
+ wizard = self.env["res.config.settings"].create({})
+ with Form(wizard) as form:
+ form.openwebui_provider_id = self.provider
+ form.openwebui_default_model_id = model
+ self.assertEqual(self.env.company.openwebui_provider_id, self.provider)
+ self.assertEqual(self.env.company.openwebui_default_model_id, model)
+
+ def test_openwebui_chat(self):
+ model = self.provider.model_ids.filtered(
+ lambda model: model.technical_name == "MS.qwen3:32b-q8_0"
+ )
+ self.env.company.openwebui_default_model_id = model
+ self.env.company.openwebui_provider_id = self.provider
+ response = self.provider.get_client().chat.completions.create(
+ model=model.technical_name,
+ messages=[
+ {
+ "role": "user",
+ "content": "Hello, how are you?",
+ }
+ ],
+ )
+ self.assertTrue(response and response.choices)
diff --git a/openwebui_base/views/openwebui_model_views.xml b/openwebui_base/views/openwebui_model_views.xml
new file mode 100644
index 0000000..e3c1c2b
--- /dev/null
+++ b/openwebui_base/views/openwebui_model_views.xml
@@ -0,0 +1,78 @@
+
+
+
+
+ openwebui.model.form
+ openwebui.model
+
+
+ Enable AI-powered sale order generation from helpdesk tickets
+
+
+
+
+
+
+
+
+
+ Settings
+ res.config.settings
+
+ form
+ inline
+ {'module' : 'openwebui_base', 'bin_size': False}
+
+
\ No newline at end of file
diff --git a/openwebui_connector/__init__.py b/openwebui_connector/__init__.py
deleted file mode 100644
index cde864b..0000000
--- a/openwebui_connector/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from . import models
diff --git a/openwebui_connector/__manifest__.py b/openwebui_connector/__manifest__.py
deleted file mode 100644
index 0eb250c..0000000
--- a/openwebui_connector/__manifest__.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# -*- coding: utf-8 -*-
-{
- 'name': 'OpenWebUI Connector',
- 'version': '18.0.1.0.0',
- 'category': 'Tools',
- 'summary': 'Connect to OpenWebUI and other AI services',
- 'description': """
-OpenWebUI Connector
-===============
-This module provides integration with OpenWebUI and other AI services.
-It allows other modules to use AI capabilities through a standardized interface.
- """,
- 'author': 'Bemade',
- 'website': 'https://www.bemade.org',
- 'maintainer': 'it@bemade.org',
- 'depends': ['base'],
- 'data': [
- 'security/ir.model.access.csv',
- 'data/ai_prompt_template_data.xml',
- 'views/res_config_settings_views.xml',
- 'views/ai_prompt_template_views.xml',
- ],
- 'installable': True,
- 'application': False,
- 'auto_install': False,
- 'license': 'LGPL-3',
- 'external_dependencies': {
- 'python': ['requests'],
- },
-}
diff --git a/openwebui_connector/data/ai_prompt_template_data.xml b/openwebui_connector/data/ai_prompt_template_data.xml
deleted file mode 100644
index b07b0ca..0000000
--- a/openwebui_connector/data/ai_prompt_template_data.xml
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
- Default Helpdesk Template
- helpdesk
-
- 10
-
- You are a helpful AI assistant for Bemade. Your task is to analyze a helpdesk ticket and suggest products or services that should be included in a sales order.
-
-TICKET INFORMATION:
-Customer: {customer}
-Subject: {subject}
-Description: {description}
-
-Based on the ticket information above, please suggest products or services that should be included in a sales order. Format your response as a list of products with quantities. If possible, match to existing products in our catalog.
-
-For each product suggestion, include:
-1. Product name
-2. Quantity
-3. Brief justification for including this item
-
-Format your response as follows:
-```
-PRODUCT SUGGESTIONS:
-- 2x Product Name: Justification
-- 1x Another Product: Justification
-```
-
-If you need more information to make accurate suggestions, please indicate what information is missing.
-
-
-
-
- Default General Template
- general
-
- 20
-
- You are a helpful AI assistant for Bemade. Please analyze the following content and provide a concise and helpful response:
-
-{content}
-
-Please provide a clear and professional response.
-
-
-
diff --git a/openwebui_connector/migrations/1.0/__init__.py b/openwebui_connector/migrations/1.0/__init__.py
deleted file mode 100644
index 7af1d05..0000000
--- a/openwebui_connector/migrations/1.0/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# -*- coding: utf-8 -*-
-# This file is intentionally left empty to make the directory a proper Python package
diff --git a/openwebui_connector/migrations/1.0/post-migration.py b/openwebui_connector/migrations/1.0/post-migration.py
deleted file mode 100644
index 03f7e9c..0000000
--- a/openwebui_connector/migrations/1.0/post-migration.py
+++ /dev/null
@@ -1,56 +0,0 @@
-# -*- coding: utf-8 -*-
-# This migration script will run after the module update
-# It will ensure the system parameters are properly set up
-
-import logging
-_logger = logging.getLogger(__name__)
-
-def migrate(cr, version):
- """
- Ensure system parameters are properly set up after migration
- """
- # Set default values for system parameters
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value, create_date, write_date)
- VALUES ('openwebui.base_url', 'https://ai.bemade.org/api', now(), now())
- ON CONFLICT (key) DO NOTHING
- """)
-
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value, create_date, write_date)
- VALUES ('openwebui.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
- ON CONFLICT (key) DO NOTHING
- """)
-
- # Log the migration
- cr.execute("""
- INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
- VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
- 'Post-migration: Set default system parameters',
- '/addons/openwebui_connector/migrations/1.0/post-migration.py', 30, 'migrate')
- """)
-
- # Check if helpdesk_sale_order_ai module is installed
- cr.execute("""
- SELECT id FROM ir_module_module
- WHERE name = 'helpdesk_sale_order_ai'
- AND state = 'installed'
- """)
-
- if cr.fetchone():
- _logger.info("helpdesk_sale_order_ai module is installed, ensuring parameters are set")
-
- # Ensure the helpdesk AI parameter exists
- cr.execute("""
- INSERT INTO ir_config_parameter (key, value, create_date, write_date)
- VALUES ('helpdesk_sale_order_ai.use_ai_sale_orders', 'False', now(), now())
- ON CONFLICT (key) DO NOTHING
- """)
-
- # Log the migration
- cr.execute("""
- INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
- VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
- 'Post-migration: Set default helpdesk AI parameters',
- '/addons/openwebui_connector/migrations/1.0/post-migration.py', 50, 'migrate')
- """)
diff --git a/openwebui_connector/migrations/1.0/pre-migration.py b/openwebui_connector/migrations/1.0/pre-migration.py
deleted file mode 100644
index 04d40b8..0000000
--- a/openwebui_connector/migrations/1.0/pre-migration.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# -*- coding: utf-8 -*-
-# This migration script will run before the module update
-# It will handle database constraints and column issues for Odoo 18 compatibility
-
-def migrate(cr, version):
- """
- Handle database constraints and column issues for Odoo 18 compatibility
- """
- # 1. Check and drop any foreign key constraints on helpdesk_ai_prompt_template_id
- cr.execute("""
- SELECT tc.constraint_name
- FROM information_schema.table_constraints tc
- JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
- WHERE tc.table_name = 'res_config_settings'
- AND ccu.column_name = 'helpdesk_ai_prompt_template_id'
- AND tc.constraint_type = 'FOREIGN KEY'
- """)
-
- constraints = cr.fetchall()
- for constraint in constraints:
- constraint_name = constraint[0]
- # Drop the constraint
- cr.execute(f"""
- ALTER TABLE res_config_settings
- DROP CONSTRAINT IF EXISTS {constraint_name}
- """)
-
- # Log the migration
- cr.execute("""
- INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
- VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
- 'Migration: Dropped foreign key constraint: %s',
- '/addons/openwebui_connector/migrations/1.0/pre-migration.py', 30, 'migrate')
- """, (constraint_name,))
-
- # 2. Check if the column exists and drop it if needed
- cr.execute("""
- SELECT column_name
- FROM information_schema.columns
- WHERE table_name = 'res_config_settings'
- AND column_name = 'helpdesk_ai_prompt_template_id'
- """)
-
- if cr.fetchone():
- # We need to drop the column to avoid conflicts with the new field
- try:
- cr.execute("""
- ALTER TABLE res_config_settings
- DROP COLUMN IF EXISTS helpdesk_ai_prompt_template_id
- """)
-
- # Log the migration
- cr.execute("""
- INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
- VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
- 'Migration: Dropped column helpdesk_ai_prompt_template_id',
- '/addons/openwebui_connector/migrations/1.0/pre-migration.py', 50, 'migrate')
- """)
- except Exception as e:
- # Log the error
- cr.execute("""
- INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
- VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'error',
- 'Migration error: %s',
- '/addons/openwebui_connector/migrations/1.0/pre-migration.py', 60, 'migrate')
- """, (str(e),))
diff --git a/openwebui_connector/migrations/18.0.1.0.0/__init__.py b/openwebui_connector/migrations/18.0.1.0.0/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/openwebui_connector/migrations/18.0.1.0.0/post-migration.py b/openwebui_connector/migrations/18.0.1.0.0/post-migration.py
deleted file mode 100644
index ba670d8..0000000
--- a/openwebui_connector/migrations/18.0.1.0.0/post-migration.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# -*- coding: utf-8 -*-
-# This migration script will run after the module update
-# It will migrate data from openai_prompt_template to openwebui_prompt_template
-
-import logging
-_logger = logging.getLogger(__name__)
-
-def migrate(cr, version):
- """
- Migrate data from openai_prompt_template to openwebui_prompt_template
- """
- if not version:
- return
-
- _logger.info("Starting migration of prompt templates from openai to openwebui")
-
- # Check if the old table exists
- cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
- if not cr.fetchone()[0]:
- _logger.info("No openai_prompt_template table found, skipping migration")
- return
-
- # Check if there's data to migrate
- cr.execute("SELECT COUNT(*) FROM openai_prompt_template")
- count = cr.fetchone()[0]
- _logger.info(f"Found {count} records to migrate from openai_prompt_template")
-
- if count > 0:
- # Copy data from old table to new table
- cr.execute("""
- INSERT INTO openwebui_prompt_template (
- id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
- )
- SELECT
- id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
- FROM
- openai_prompt_template
- ON CONFLICT (id) DO UPDATE SET
- name = EXCLUDED.name,
- content = EXCLUDED.content,
- is_default = EXCLUDED.is_default,
- module = EXCLUDED.module,
- write_uid = EXCLUDED.write_uid,
- write_date = EXCLUDED.write_date
- """)
-
- # Update sequence if needed
- cr.execute("""
- SELECT MAX(id) FROM openwebui_prompt_template
- """)
- max_id = cr.fetchone()[0] or 0
- if max_id > 0:
- cr.execute(f"""
- SELECT setval('openwebui_prompt_template_id_seq', {max_id})
- """)
-
- # Log the migration
- _logger.info(f"Successfully migrated {count} records from openai_prompt_template to openwebui_prompt_template")
-
- # Update any references in res_config_settings
- cr.execute("""
- UPDATE ir_config_parameter
- SET key = REPLACE(key, 'openai.', 'openwebui.')
- WHERE key LIKE 'openai.%'
- """)
-
- # Update any references to prompt template IDs in system parameters
- cr.execute("""
- UPDATE ir_config_parameter
- SET key = REPLACE(key, 'openai_prompt_template_id', 'openwebui_prompt_template_id')
- WHERE key LIKE '%openai_prompt_template_id%'
- """)
-
- _logger.info("Updated system parameters to use openwebui prefix")
diff --git a/openwebui_connector/migrations/18.0.1.0.0/pre-migration.py b/openwebui_connector/migrations/18.0.1.0.0/pre-migration.py
deleted file mode 100644
index 7260667..0000000
--- a/openwebui_connector/migrations/18.0.1.0.0/pre-migration.py
+++ /dev/null
@@ -1,54 +0,0 @@
-# -*- coding: utf-8 -*-
-# This migration script will run before the module update
-# It will handle database constraints and prepare for the migration
-
-import logging
-_logger = logging.getLogger(__name__)
-
-def migrate(cr, version):
- """
- Handle database constraints and prepare for migration
- """
- if not version:
- return
-
- _logger.info("Starting pre-migration for openwebui_connector")
-
- # 1. Drop foreign key constraints on ai_prompt_template_id to avoid conflicts
- cr.execute("""
- SELECT tc.constraint_name
- FROM information_schema.table_constraints tc
- JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
- WHERE tc.table_name = 'res_config_settings'
- AND ccu.column_name = 'ai_prompt_template_id'
- AND tc.constraint_type = 'FOREIGN KEY'
- """)
-
- constraints = cr.fetchall()
- for constraint in constraints:
- constraint_name = constraint[0]
- # Drop the constraint
- cr.execute(f"""
- ALTER TABLE res_config_settings
- DROP CONSTRAINT IF EXISTS {constraint_name}
- """)
- _logger.info(f"Dropped foreign key constraint: {constraint_name}")
-
- # 2. Update system parameters to use new naming
- cr.execute("""
- UPDATE ir_config_parameter
- SET key = REPLACE(key, 'openai.', 'openwebui.')
- WHERE key LIKE 'openai.%'
- """)
-
- # 3. Check if the old openai_prompt_template table exists
- cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
- if cr.fetchone()[0]:
- # Create a backup of the data if needed
- cr.execute("""
- CREATE TABLE IF NOT EXISTS openai_prompt_template_backup AS
- SELECT * FROM openai_prompt_template
- """)
- _logger.info("Created backup of openai_prompt_template data")
-
- _logger.info("Pre-migration completed successfully")
diff --git a/openwebui_connector/migrations/__init__.py b/openwebui_connector/migrations/__init__.py
deleted file mode 100644
index 7af1d05..0000000
--- a/openwebui_connector/migrations/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# -*- coding: utf-8 -*-
-# This file is intentionally left empty to make the directory a proper Python package
diff --git a/openwebui_connector/models/__init__.py b/openwebui_connector/models/__init__.py
deleted file mode 100644
index 661d16b..0000000
--- a/openwebui_connector/models/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from . import openwebui_client
-from . import ai_prompt_template
-from . import res_config_settings
diff --git a/openwebui_connector/models/ai_prompt_template.py b/openwebui_connector/models/ai_prompt_template.py
deleted file mode 100644
index 96e8692..0000000
--- a/openwebui_connector/models/ai_prompt_template.py
+++ /dev/null
@@ -1,187 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from odoo import models, fields, api, _
-import logging
-
-_logger = logging.getLogger(__name__)
-
-class AIPromptTemplate(models.Model):
- _name = 'openwebui.prompt.template'
- _description = 'AI Prompt Template'
- _order = 'sequence, id'
-
- name = fields.Char(string='Name', required=True)
- content = fields.Text(
- string='Template Content',
- required=True,
- help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.'
- )
- template_type = fields.Selection([
- ('helpdesk', 'Helpdesk'),
- ('general', 'General'),
- ], string='Template Type', default='helpdesk', required=True)
- sequence = fields.Integer(string='Sequence', default=10)
- active = fields.Boolean(string='Active', default=True)
- is_default = fields.Boolean(string='Default Template', default=False)
-
- @api.model
- def _ensure_default_template(self, template_type='helpdesk'):
- """Ensure that at least one default template exists for the given type"""
- _logger.info(f"Ensuring default template exists for type: {template_type}")
- try:
- default_template = self.search([('template_type', '=', template_type), ('is_default', '=', True)], limit=1)
- _logger.info(f"Found existing default template: {bool(default_template)}")
-
- if not default_template:
- _logger.info("No default template found, creating one")
- # Create a default template
- default_content = self._get_default_template_content(template_type)
- default_name = f"Default {template_type.capitalize()} Template"
-
- try:
- _logger.info(f"Creating default template with name: {default_name}")
- default_template = self.create({
- 'name': default_name,
- 'template_type': template_type,
- 'content': default_content,
- 'is_default': True,
- })
- _logger.info(f"Default template created with ID: {default_template.id}")
-
- # Set the default template ID in system parameters
- if template_type == 'helpdesk':
- _logger.info("Setting default template ID in system parameters")
- param_name = 'helpdesk_sale_order_ai.default_prompt_template_id'
- param_value = str(default_template.id)
- _logger.info(f"Setting parameter {param_name} = {param_value}")
-
- self.env['ir.config_parameter'].sudo().set_param(
- param_name, param_value
- )
- except Exception as e:
- _logger.error(f"Error creating default template: {e}", exc_info=True)
- _logger.error(f"Template data: name={default_name}, type={template_type}, content_length={len(default_content) if default_content else 0}")
-
- return default_template
- except Exception as e:
- _logger.error(f"Unexpected error in _ensure_default_template: {e}", exc_info=True)
- return False
-
- @api.model
- def _get_default_template_content(self, template_type):
- """Get the default content for a template type"""
- if template_type == 'helpdesk':
- return """Based on the following helpdesk ticket information, create a complete sales order that accurately reflects the client's requirements.
-
-Ticket Information: {description}
-Customer: {customer}
-Subject: {subject}
-
-Analyze all information including ticket description, chatter messages, and attachments to create a comprehensive sales order that includes ALL items the client has requested.
-
-Please provide a detailed list of products and services with exact quantities, specifications, and any relevant details mentioned by the client. Include part numbers when available.
-
-Format your response as follows:
-- [Quantity]x [Product Name] [Part Number if available]: [Specifications/Details]
-
-For example:
-- 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency as specified in technical document
-- 1x Preventive Maintenance Service: Annual service package including parts and labor
-- 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI max pressure
-
-If the client has provided pricing expectations or budget constraints, include this information. If specific delivery timeframes are mentioned, note these as well.
-"""
- elif template_type == 'general':
- return """You are a helpful AI assistant. Please analyze the following content and provide a concise and helpful response:
-
-{content}
-"""
- else:
- return """Please provide a prompt template for this type of content."""
-
- def set_as_default(self):
- """Set this template as the default for its type"""
- # First, unset any existing default templates of the same type
- other_defaults = self.search([
- ('id', '!=', self.id),
- ('template_type', '=', self.template_type),
- ('is_default', '=', True)
- ])
- other_defaults.write({'is_default': False})
-
- # Set this template as default
- self.is_default = True
-
- # Update the system parameter if this is a helpdesk template
- if self.template_type == 'helpdesk':
- self.env['ir.config_parameter'].sudo().set_param(
- 'helpdesk_sale_order_ai.default_prompt_template_id',
- str(self.id)
- )
-
- return True
-
- @api.model_create_multi
- def create(self, vals_list):
- """Override create to handle default templates"""
- records = super(AIPromptTemplate, self).create(vals_list)
-
- # Group templates by type
- templates_by_type = {}
- for record in records:
- if record.is_default:
- if record.template_type not in templates_by_type:
- templates_by_type[record.template_type] = []
- templates_by_type[record.template_type].append(record.id)
-
- # For each type with new default templates, unset default flag on others of the same type
- for template_type, template_ids in templates_by_type.items():
- self.search([
- ('id', 'not in', template_ids),
- ('is_default', '=', True),
- ('template_type', '=', template_type)
- ]).write({'is_default': False})
-
- return records
-
- @api.model
- def get_default_template(self, template_type='helpdesk'):
- """Get the default template for the given type"""
- _logger.info(f"Getting default template for type: {template_type}")
- try:
- default_template = self.search([
- ('is_default', '=', True),
- ('template_type', '=', template_type),
- ('active', '=', True),
- ], limit=1)
-
- _logger.info(f"Found default template: {bool(default_template)}")
-
- if not default_template:
- _logger.info("No active default template found, ensuring one exists")
- # Create a default template if none exists
- default_template = self._ensure_default_template(template_type)
- _logger.info(f"Default template after ensure: {bool(default_template)} with ID: {default_template.id if default_template else 'None'}")
- else:
- _logger.info(f"Using existing default template with ID: {default_template.id}")
-
- return default_template
- except Exception as e:
- _logger.error(f"Error in get_default_template: {e}", exc_info=True)
- # Try to create a new template as a fallback
- try:
- _logger.info("Attempting to create a new default template as fallback")
- default_content = self._get_default_template_content(template_type)
- default_name = f"Fallback {template_type.capitalize()} Template"
-
- fallback_template = self.create({
- 'name': default_name,
- 'template_type': template_type,
- 'content': default_content,
- 'is_default': True,
- })
- _logger.info(f"Created fallback template with ID: {fallback_template.id}")
- return fallback_template
- except Exception as e2:
- _logger.error(f"Failed to create fallback template: {e2}", exc_info=True)
- return self.browse() # Return empty recordset
diff --git a/openwebui_connector/models/openwebui_client.py b/openwebui_connector/models/openwebui_client.py
deleted file mode 100644
index b01dded..0000000
--- a/openwebui_connector/models/openwebui_client.py
+++ /dev/null
@@ -1,359 +0,0 @@
-# -*- coding: utf-8 -*-
-from odoo import models, fields, api, _
-import logging
-import json
-import requests
-from typing import Dict, List, Any, Optional, Tuple
-
-_logger = logging.getLogger(__name__)
-
-class OpenWebUIClient(models.AbstractModel):
- """
- OpenWebUI client for interacting with the OpenWebUI API.
- This is a simplified version of the external OpenWebUI client integrated into Odoo.
- """
- _name = 'openwebui.client'
- _description = 'OpenWebUI Client for AI Services'
-
- def _get_config(self, raise_if_missing=True):
- """
- Get the OpenWebUI configuration from the system parameters.
- Uses the dedicated openwebui parameters for API key, base URL and model.
-
- Args:
- raise_if_missing: If True, raise an error when API key is missing.
- If False, return config with empty API key.
- """
- _logger.debug(f"Getting OpenWebUI config (raise_if_missing={raise_if_missing})")
- try:
- # Get the config from the ir.config_parameter
- IrConfigParam = self.env['ir.config_parameter'].sudo()
-
- # Use dedicated OpenWebUI parameters
- api_key = IrConfigParam.get_param('openwebui.api_key', False)
-
- # If OpenWebUI API key is not set, try fallback to OpenAI key
- if not api_key:
- api_key = IrConfigParam.get_param('openai.api_key', False)
- _logger.debug("OpenWebUI API key not found, falling back to OpenAI API key")
-
- _logger.debug(f"Retrieved API key: {'Present' if api_key else 'Missing'}")
-
- # Get base URL from dedicated parameter
- base_url = IrConfigParam.get_param('openwebui.base_url', 'https://ai.bemade.org/api')
- _logger.debug(f"Using base_url: {base_url}")
-
- # Get model from dedicated parameter
- model = IrConfigParam.get_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
- _logger.debug(f"Using model: {model}")
-
- if not api_key and raise_if_missing:
- _logger.error("No API key found in configuration and raise_if_missing=True")
- raise ValueError("No API key found in configuration")
-
- config = {
- 'api_key': api_key or '',
- 'base_url': base_url,
- 'model': model
- }
- _logger.debug("Successfully retrieved OpenWebUI config")
- return config
- except Exception as e:
- _logger.error(f"Error getting OpenWebUI config: {e}", exc_info=True)
- raise
-
- def get_available_models(self):
- """
- Fetch available models from the OpenWebUI API.
-
- Returns:
- A list of tuples (model_id, model_name) suitable for selection fields
- """
- _logger.info("Starting get_available_models")
- # Define default_model at the beginning to ensure it's always available
- default_model = 'anthropic.claude-3-7-sonnet-latest'
-
- # Create a list with just the default model to ensure it's always available
- models = [(default_model, default_model)]
-
- try:
- # Get config without raising exception if API key is missing
- config = self._get_config(raise_if_missing=False)
-
- # Update default_model with configured value and ensure it's in the list
- if 'model' in config and config['model']:
- default_model = config['model']
- # Clear the list and add the current default model
- models = [(default_model, default_model)]
-
- _logger.info(f"Default model: {default_model}")
-
- # If no API key is set, just return the current model
- if not config.get('api_key'):
- _logger.warning("No API key configured, only showing current model")
- return models
-
- # Add some common models that we know work with OpenWebUI
- # This ensures we have a good selection even if the API call fails
- common_models = [
- ('anthropic.claude-3-7-sonnet-latest', 'Claude 3.7 Sonnet'),
- ('anthropic.claude-3-5-sonnet-20240620', 'Claude 3.5 Sonnet'),
- ('anthropic.claude-3-opus-20240229', 'Claude 3 Opus'),
- ('gpt-4o', 'GPT-4o'),
- ('gpt-4-turbo', 'GPT-4 Turbo'),
- ('gpt-3.5-turbo', 'GPT-3.5 Turbo')
- ]
-
- # Add common models to our list, but keep the default model first
- for model_id, model_name in common_models:
- if model_id != default_model: # Avoid duplicates
- models.append((model_id, model_name))
-
- # Remove trailing slash if present in base_url
- base_url = config.get('base_url', 'https://ai.bemade.org/api')
- if isinstance(base_url, str) and base_url.endswith('/'):
- base_url = base_url[:-1]
- _logger.info(f"Using base URL: {base_url}")
-
- # Check if base_url already contains '/api' to avoid duplicates
- if '/api' in base_url:
- endpoints = [
- '/v1/models',
- '/models',
- '/chat/models',
- '/v1/chat/models'
- ]
- else:
- endpoints = [
- '/v1/models',
- '/models',
- '/chat/models',
- '/api/models',
- '/api/v1/models',
- '/api/chat/models'
- ]
- _logger.debug(f"Will try these endpoints: {endpoints}")
-
- # Set up authentication headers
- headers = {
- "Authorization": f"Bearer {config['api_key']}",
- "Content-Type": "application/json",
- }
- _logger.debug("Authentication headers set up")
-
- # Try a simple connection test first with a short timeout
- try:
- _logger.info(f"Testing connection to base URL: {base_url}")
- test_response = requests.get(base_url, timeout=3)
- _logger.info(f"Base API connection test: {test_response.status_code}")
- if test_response.status_code >= 400:
- _logger.warning(f"Base URL returned error status: {test_response.status_code}")
- return models # Return our predefined models
- except Exception as e:
- _logger.warning(f"Could not connect to base API URL: {str(e)}")
- # Return our predefined models if we can't connect
- _logger.info("Returning predefined models due to connection error")
- return models
-
- # Try each endpoint with a short timeout
- _logger.info("Starting to try each endpoint for models")
- success = False
-
- for endpoint in endpoints:
- url = f"{base_url}{endpoint}"
- _logger.info(f"Trying endpoint: {url}")
-
- try:
- _logger.debug(f"Making request to {url} with timeout=5")
- response = requests.get(url, headers=headers, timeout=5)
- _logger.info(f"Response status code: {response.status_code}")
-
- if response.status_code != 200:
- _logger.info(f"Endpoint {endpoint} returned non-200 status code: {response.status_code}")
- continue
-
- # Check if response is empty
- if not response.text or response.text.strip() == '':
- _logger.warning(f"Empty response from {url}")
- continue
-
- # Try to parse the response as JSON
- try:
- _logger.debug("Parsing response as JSON")
- response_data = response.json()
- _logger.debug(f"Response data type: {type(response_data)}")
-
- # If we got an empty object or list, skip
- if (isinstance(response_data, dict) and not response_data) or \
- (isinstance(response_data, list) and not response_data):
- _logger.warning(f"Empty JSON object/array from {url}")
- continue
-
- except json.JSONDecodeError as je:
- _logger.error(f"Failed to parse JSON from {url}: {je}")
- continue
-
- # Extract model information from the response
- model_ids = []
-
- # Handle different response formats
- if isinstance(response_data, dict):
- _logger.debug("Response is a dictionary")
- # OpenAI format
- if "data" in response_data and isinstance(response_data["data"], list):
- _logger.debug("Found OpenAI format response with 'data' key")
- for model in response_data["data"]:
- if isinstance(model, dict) and "id" in model:
- model_id = model["id"]
- model_name = model.get("name", model_id)
- model_ids.append((model_id, model_name))
- # Another common format
- elif "models" in response_data and isinstance(response_data["models"], list):
- _logger.debug("Found response with 'models' key")
- for model in response_data["models"]:
- if isinstance(model, dict) and "id" in model:
- model_id = model["id"]
- model_name = model.get("name", model_id)
- model_ids.append((model_id, model_name))
- else:
- _logger.debug(f"Dictionary response keys: {list(response_data.keys())}")
- elif isinstance(response_data, list):
- _logger.debug("Response is a list")
- # Simple list format
- for model in response_data:
- if isinstance(model, dict) and "id" in model:
- model_id = model["id"]
- model_name = model.get("name", model_id)
- model_ids.append((model_id, model_name))
- else:
- _logger.warning(f"Unexpected response data type: {type(response_data)}")
-
- if model_ids:
- _logger.info(f"Found {len(model_ids)} models from endpoint {url}")
- # Add all found models to our list, avoiding duplicates
- for model_tuple in model_ids:
- if model_tuple not in models:
- models.append(model_tuple)
- success = True
- break
- else:
- _logger.warning(f"No models found in response from {url}")
-
- except requests.RequestException as e:
- _logger.error(f"Request error with endpoint {endpoint}: {e}")
- continue
- except Exception as e:
- _logger.error(f"Unexpected error with endpoint {endpoint}: {e}")
- continue
-
- # If we couldn't find any models from API, we still have our predefined models
- _logger.info(f"Total models found: {len(models)}")
- return models
-
- except Exception as e:
- _logger.error(f"Error in get_available_models: {e}")
- # Return just the default model on any error
- return [(default_model, default_model)]
-
- def chat_completion(self, messages, model=None):
- """
- Send a chat completion request to the OpenWebUI API.
-
- Args:
- messages: List of message dictionaries with 'role' and 'content' keys
- model: Model to use (defaults to the configured model)
-
- Returns:
- The response from the OpenWebUI API
- """
- _logger.info("Starting chat_completion request")
- try:
- # For chat completion, we do need a valid API key
- _logger.info("Getting config for chat completion")
- config = self._get_config(raise_if_missing=True)
-
- # Use the provided model or fall back to the configured model
- model = model or config['model']
- _logger.info(f"Using model: {model}")
-
- # Remove trailing slash if present in base_url
- base_url = config['base_url']
- if isinstance(base_url, str) and base_url.endswith('/'):
- base_url = base_url[:-1]
- _logger.info(f"Using base URL: {base_url}")
-
- # Construct the full URL based on the API provider
- if 'openai.com' in base_url:
- # Standard OpenAI API format
- url = f"{base_url}/chat/completions"
- elif 'bemade.org' in base_url:
- # For Bemade's API, try without the v1 path as it's returning 405
- url = f"{base_url}/chat/completions"
- else:
- # Generic format, try standard OpenAI path
- url = f"{base_url}/chat/completions"
-
- _logger.info(f"Full API URL: {url}")
-
- # Set up authentication headers
- headers = {
- "Authorization": f"Bearer {config['api_key']}",
- "Content-Type": "application/json",
- }
- _logger.debug("Authentication headers set up")
-
- # Create the payload
- payload = {
- "model": model,
- "messages": messages,
- "temperature": 0.7, # Add reasonable temperature for more consistent results
- "max_tokens": 4000 # Ensure we get enough tokens for a complete response
- }
-
- _logger.info(f"OpenWebUI API request to: {url}")
- _logger.debug(f"OpenWebUI API payload: {payload}")
-
- try:
- # Make the HTTP request
- _logger.info("Sending POST request to OpenWebUI API")
- response = requests.post(url, headers=headers, json=payload, timeout=60.0)
- _logger.info(f"Response status code: {response.status_code}")
-
- # Log response content for debugging
- _logger.debug(f"Response content (first 500 chars): {response.text[:500]}")
-
- # Raise an exception for any HTTP error
- response.raise_for_status()
- _logger.info("Response status check passed")
-
- # Parse the JSON response
- try:
- _logger.debug("Parsing response as JSON")
- response_data = response.json()
- except json.JSONDecodeError as je:
- _logger.error(f"Failed to parse JSON response: {je}", exc_info=True)
- _logger.error(f"Response content: {response.text[:500]}")
- return ""
-
- _logger.debug(f"Response data type: {type(response_data)}")
- if isinstance(response_data, dict):
- _logger.debug(f"Response keys: {list(response_data.keys())}")
-
- # Extract the content from the response
- if response_data and 'choices' in response_data and response_data['choices']:
- _logger.info("Successfully extracted content from response")
- return response_data['choices'][0]['message']['content']
- else:
- _logger.error(f"Invalid response format from OpenWebUI API: {response_data}")
- return ""
-
- except requests.RequestException as re:
- _logger.error(f"Request error calling OpenWebUI API: {re}", exc_info=True)
- raise
- except Exception as e:
- _logger.error(f"Unexpected error in API request: {e}", exc_info=True)
- raise
-
- except Exception as e:
- _logger.error(f"Error in chat_completion: {e}", exc_info=True)
- raise
diff --git a/openwebui_connector/models/res_config_settings.py b/openwebui_connector/models/res_config_settings.py
deleted file mode 100644
index af97b56..0000000
--- a/openwebui_connector/models/res_config_settings.py
+++ /dev/null
@@ -1,382 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from odoo import models, fields, api, _
-import logging
-
-_logger = logging.getLogger(__name__)
-
-class ResConfigSettings(models.TransientModel):
- _inherit = 'res.config.settings'
-
- # AI API Configuration (unified for all AI services)
- openai_api_key = fields.Char(
- string='AI API Key',
- help='API key for AI services (OpenAI, OpenWebUI, Claude)',
- config_parameter='openwebui.api_key',
- )
-
- openai_base_url = fields.Char(
- string='AI Base URL',
- help='Base URL for AI API',
- default='https://api.openai.com/v1', # Default OpenAI endpoint
- config_parameter='openwebui.base_url',
- )
-
- # Use Selection field with dynamic options from OpenWebUI client
- openai_model = fields.Selection(
- selection='_get_openwebui_models',
- string='AI Model',
- help='Model to use for AI API calls',
- default='anthropic.claude-3-7-sonnet-latest',
- config_parameter='openwebui.model',
- )
-
- # Helpdesk AI fields
- helpdesk_use_ai_sale_orders = fields.Boolean(
- string='Use AI for Sale Orders',
- help='If checked, the system will use AI to automatically generate sale orders from helpdesk ticket descriptions.',
- config_parameter='helpdesk_sale_order_ai.use_ai_sale_orders',
- )
-
- # Add a field to select the prompt template directly
- # In Odoo 18, we need to ensure this field is properly defined
- ai_prompt_template_id = fields.Integer(
- string='Default AI Prompt Template ID',
- help='Default template ID for the prompt sent to the AI.',
- compute='_compute_ai_prompt_template_id',
- inverse='_inverse_ai_prompt_template_id',
- store=False, # Don't store this field to avoid constraint issues
- )
-
- # Actual relation field for the template, but not stored in database
- ai_prompt_template_relation = fields.Many2one(
- 'openwebui.prompt.template',
- string='Default AI Prompt Template',
- help='Default template for the prompt sent to the AI.',
- compute='_compute_ai_prompt_template_relation',
- domain="[('template_type', '=', 'helpdesk')]",
- store=False, # Don't store this field to avoid constraint issues
- )
-
- # Display field for backward compatibility
- helpdesk_ai_template_display = fields.Char(
- string='Default AI Prompt Template',
- help='Default template for the prompt sent to the AI.',
- compute='_compute_helpdesk_ai_template_display',
- readonly=True,
- store=False, # Important: don't store this field in the database
- )
-
- @api.model
- def _get_openwebui_models(self):
- """Get available models from OpenWebUI API"""
- try:
- client = self.env['openwebui.client']
- models = client.get_available_models()
- return models
- except Exception as e:
- _logger.error(f"Error fetching OpenWebUI models: {e}")
- # Return default model on error
- default_model = 'anthropic.claude-3-7-sonnet-latest'
- return [(default_model, default_model)]
-
- def _compute_ai_prompt_template_id(self):
- """Compute the template ID from system parameter"""
- for record in self:
- IrConfigParam = self.env['ir.config_parameter'].sudo()
- template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
-
- if template_id:
- try:
- # Convert to integer if it's a string
- template_id_int = int(template_id) if isinstance(template_id, str) else template_id
- record.ai_prompt_template_id = template_id_int
- except (ValueError, TypeError):
- record.ai_prompt_template_id = False
- else:
- record.ai_prompt_template_id = False
-
- def _inverse_ai_prompt_template_id(self):
- """Save the template ID to system parameter"""
- for record in self:
- IrConfigParam = self.env['ir.config_parameter'].sudo()
- if record.ai_prompt_template_id:
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(record.ai_prompt_template_id))
- else:
- # Try to get a default template
- default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
- if default_template:
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
-
- def _compute_ai_prompt_template_relation(self):
- """Compute the template relation from the ID"""
- for record in self:
- if record.ai_prompt_template_id:
- template = self.env['openwebui.prompt.template'].browse(record.ai_prompt_template_id)
- if template.exists():
- record.ai_prompt_template_relation = template.id
- else:
- record.ai_prompt_template_relation = False
- else:
- record.ai_prompt_template_relation = False
-
- @api.depends('ai_prompt_template_relation')
- def _compute_helpdesk_ai_template_display(self):
- """Compute the template name from the selected template"""
- for record in self:
- if record.ai_prompt_template_relation:
- record.helpdesk_ai_template_display = record.ai_prompt_template_relation.name
- else:
- # Try to get from system parameter for backward compatibility
- IrConfigParam = self.env['ir.config_parameter'].sudo()
- template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
-
- if template_id:
- try:
- # Convert to integer if it's a string
- template_id_int = int(template_id) if isinstance(template_id, str) else template_id
-
- # Try to find the template in the new model
- template = self.env['openwebui.prompt.template'].browse(template_id_int)
- if template.exists():
- record.helpdesk_ai_template_display = template.name
- else:
- record.helpdesk_ai_template_display = f'Template #{template_id} (not found)'
- except (ValueError, TypeError):
- record.helpdesk_ai_template_display = f'Template #{template_id} (invalid)'
- else:
- record.helpdesk_ai_template_display = 'Default Template'
-
- @api.model
- def _set_default_values(self):
- """Set default values for configuration parameters"""
- IrConfigParam = self.env['ir.config_parameter'].sudo()
- base_url = IrConfigParam.get_param('openwebui.base_url')
- model = IrConfigParam.get_param('openwebui.model')
-
- # Check for old parameters and migrate them
- old_api_key = IrConfigParam.get_param('openai.api_key', False)
- old_base_url = IrConfigParam.get_param('openai.base_url', False)
- old_model = IrConfigParam.get_param('openai.model', False)
-
- # Migrate old parameters to new ones if they exist
- if old_api_key and not IrConfigParam.get_param('openwebui.api_key', False):
- IrConfigParam.set_param('openwebui.api_key', old_api_key)
-
- if old_base_url and not base_url:
- IrConfigParam.set_param('openwebui.base_url', old_base_url)
-
- if old_model and not model:
- IrConfigParam.set_param('openwebui.model', old_model)
-
- # Set defaults if not already set
- if not base_url:
- IrConfigParam.set_param('openwebui.base_url', 'https://api.openai.com/v1')
-
- if not model:
- IrConfigParam.set_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
-
- # Ensure there's a default template
- template_model = self.env['openwebui.prompt.template']
- template_model._ensure_default_template('helpdesk')
- default_template = template_model.get_default_template('helpdesk')
-
- # Set the default template ID in config parameters if not set
- default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
- if not default_template_id and default_template:
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
-
- @api.model
- def create(self, vals_list):
- """Set default values when creating settings"""
- self._set_default_values()
- return super(ResConfigSettings, self).create(vals_list)
-
- def set_values(self):
- """Set values from the settings form"""
- _logger.info("Starting set_values in ResConfigSettings")
- try:
- # First, explicitly save the API key, base URL, and model to ensure they're saved
- # even if there's an error with the template handling
- IrConfigParam = self.env['ir.config_parameter'].sudo()
-
- # Save API key, base URL, and model directly with explicit error handling
- # API Key - most critical setting
- if hasattr(self, 'openai_api_key'):
- _logger.info(f"Saving API key (present: {bool(self.openai_api_key)})")
- # Always set the parameter, even if None or False, to clear previous value if needed
- api_key_value = self.openai_api_key if self.openai_api_key else ''
- IrConfigParam.set_param('openai.api_key', api_key_value)
- # Verify it was saved
- saved_key = IrConfigParam.get_param('openai.api_key', '')
- _logger.info(f"API key saved successfully: {bool(saved_key)}")
-
- # Base URL
- if hasattr(self, 'openai_base_url'):
- _logger.info(f"Saving base URL: {self.openai_base_url}")
- base_url_value = self.openai_base_url if self.openai_base_url else 'https://ai.bemade.org/api'
- IrConfigParam.set_param('openai.base_url', base_url_value)
- # Verify it was saved
- saved_url = IrConfigParam.get_param('openai.base_url', '')
- _logger.info(f"Base URL saved: {saved_url}")
-
- # Model
- if hasattr(self, 'openai_model'):
- _logger.info(f"Saving model: {self.openai_model}")
- model_value = self.openai_model if self.openai_model else 'anthropic.claude-3-7-sonnet-latest'
- IrConfigParam.set_param('openai.model', model_value)
- # Verify it was saved
- saved_model = IrConfigParam.get_param('openai.model', '')
- _logger.info(f"Model saved: {saved_model}")
-
- _logger.info(f"Current settings values: openai_api_key={bool(self.openai_api_key)}, openai_base_url={self.openai_base_url}, openai_model={self.openai_model}")
- _logger.info(f"Template ID: {self.ai_prompt_template_id if self.ai_prompt_template_id else 'None'}")
-
- # Force a commit to ensure parameters are saved to database
- self.env.cr.commit()
- _logger.info("Committed parameter changes to database")
-
- # Now call the super method
- res = super(ResConfigSettings, self).set_values()
- _logger.info("Super set_values completed successfully")
-
- # Handle the template ID - either save the selected one or ensure a default exists
- try:
- if self.ai_prompt_template_id:
- _logger.info(f"Saving template ID {self.ai_prompt_template_id} to system parameters")
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.ai_prompt_template_id))
- else:
- # If no template is selected, ensure a default one exists and use that
- _logger.info("No template selected, getting default template")
- template_model = self.env['openwebui.prompt.template']
- try:
- default_template = template_model.get_default_template('helpdesk')
- if default_template:
- _logger.info(f"Using default template ID {default_template.id}")
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
- else:
- _logger.warning("No default template found")
- except Exception as e:
- _logger.error(f"Error getting default template: {e}", exc_info=True)
- # Continue execution even if there's an error with the template
- except Exception as template_error:
- _logger.error(f"Error handling template: {template_error}", exc_info=True)
- # Continue execution even if there's an error with the template
-
- # Force another commit to ensure all changes are saved
- self.env.cr.commit()
- _logger.info("Final commit completed")
-
- # Double-check that the API key was saved
- final_api_key = IrConfigParam.get_param('openai.api_key', '')
- _logger.info(f"Final API key check - key exists: {bool(final_api_key)}")
-
- _logger.info("set_values completed successfully")
- return res
- except Exception as e:
- _logger.error(f"Error in set_values: {e}", exc_info=True)
- # Try to save API key directly even if there was an error
- try:
- if hasattr(self, 'openai_api_key'):
- api_key_value = self.openai_api_key if self.openai_api_key else ''
- self.env['ir.config_parameter'].sudo().set_param('openai.api_key', api_key_value)
- self.env.cr.commit() # Force commit to save the key
- _logger.info("Saved API key directly after error")
- except Exception as key_error:
- _logger.error(f"Failed to save API key after error: {key_error}")
- raise
-
- @api.model
- def get_values(self):
- """Get values for the settings form"""
- res = super(ResConfigSettings, self).get_values()
-
- # Ensure default values are set
- self._set_default_values()
-
- # Get all OpenAI settings from system parameters
- IrConfigParam = self.env['ir.config_parameter'].sudo()
-
- # Get OpenAI API key, base URL, and model
- _logger.info("Retrieving OpenAI settings from system parameters")
- api_key = IrConfigParam.get_param('openai.api_key', False)
- base_url = IrConfigParam.get_param('openai.base_url', 'https://ai.bemade.org/api')
- model = IrConfigParam.get_param('openai.model', 'anthropic.claude-3-7-sonnet-latest')
-
- # Set the values in the result dictionary
- res.update({
- 'openai_api_key': api_key,
- 'openai_base_url': base_url,
- 'openai_model': model,
- })
-
- _logger.info(f"Retrieved settings: API key present: {bool(api_key)}, base_url: {base_url}, model: {model}")
-
- # Get the template ID from the system parameter
- template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
-
- if template_id:
- try:
- template_id_int = int(template_id) if isinstance(template_id, str) else template_id
- # Check if the template exists
- template = self.env['openwebui.prompt.template'].browse(template_id_int)
- if template.exists():
- res['ai_prompt_template_id'] = template_id_int
- else:
- # If template doesn't exist, get a default one
- default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
- if default_template:
- res['ai_prompt_template_id'] = default_template.id
- # Update the system parameter
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
- except (ValueError, TypeError):
- _logger.error(f"Invalid template ID in system parameter: {template_id}")
- # Get a default template
- default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
- if default_template:
- res['ai_prompt_template_id'] = default_template.id
- # Update the system parameter
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
- else:
- # No template ID in system parameters, get a default one
- default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
- if default_template:
- res['ai_prompt_template_id'] = default_template.id
- # Update the system parameter
- IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
-
- return res
-
- def action_open_prompt_templates(self):
- """Open the prompt templates management view"""
- self.ensure_one()
-
- return {
- 'name': _('AI Prompt Templates'),
- 'type': 'ir.actions.act_window',
- 'res_model': 'openwebui.prompt.template',
- 'view_mode': 'list,form',
- }
-
- @api.model
- def get_prompt_template(self, template_type='helpdesk'):
- """Get the prompt template content from the selected template or default"""
- IrConfigParam = self.env['ir.config_parameter'].sudo()
- template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
-
- if template_id:
- try:
- # Make sure the template model exists first
- self.env['openwebui.prompt.template']._ensure_default_template(template_type)
- # The template_id is already an integer in the database now
- template_id_int = int(template_id) if isinstance(template_id, str) else template_id
- template = self.env['openwebui.prompt.template'].browse(template_id_int)
- if template.exists():
- return template.content
- except (ValueError, TypeError) as e:
- _logger.error(f"Error retrieving prompt template: {e}")
- pass
-
- # Fallback to default template
- default_template = self.env['openwebui.prompt.template'].get_default_template(template_type)
- return default_template.content if default_template else ""
diff --git a/openwebui_connector/security/ir.model.access.csv b/openwebui_connector/security/ir.model.access.csv
deleted file mode 100644
index abf7f47..0000000
--- a/openwebui_connector/security/ir.model.access.csv
+++ /dev/null
@@ -1,5 +0,0 @@
-id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
-access_openwebui_prompt_template_user,openwebui.prompt.template.user,model_openwebui_prompt_template,base.group_user,1,0,0,0
-access_openwebui_prompt_template_admin,openwebui.prompt.template.admin,model_openwebui_prompt_template,base.group_system,1,1,1,1
-access_openwebui_client_user,openwebui.client.user,model_openwebui_client,base.group_user,1,0,0,0
-access_openwebui_client_admin,openwebui.client.admin,model_openwebui_client,base.group_system,1,1,1,1
diff --git a/openwebui_connector/views/ai_prompt_template_views.xml b/openwebui_connector/views/ai_prompt_template_views.xml
deleted file mode 100644
index 927299c..0000000
--- a/openwebui_connector/views/ai_prompt_template_views.xml
+++ /dev/null
@@ -1,109 +0,0 @@
-
-
-
-
- openwebui.prompt.template.form
- openwebui.prompt.template
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Use placeholders like {description}, {customer}, etc. to include ticket information in the prompt.
-
-
- Use placeholders like {content} to include content in the prompt.
-