From ed9dda9bea614efa4bcac9d4c47bf4ff8140a0bf Mon Sep 17 00:00:00 2001 From: mathis Date: Tue, 15 Jul 2025 15:18:01 -0400 Subject: [PATCH] [ADD] openwebui_connector, helpdesk_sale_order_ai: AI-powered sales order generation from helpdesk tickets This commit introduces AI integration for helpdesk tickets to automatically generate sales orders: - openwebui_connector: New module providing integration with OpenWebUI AI service * Configurable API connection (key, base URL, model) * AI prompt template system for reusable prompts * Uses Claude 3 Sonnet model by default - helpdesk_sale_order_ai: Extends helpdesk_sale_order with AI capabilities * AI-powered analysis of ticket content to suggest products * Smart product quantity parsing from various formats * Dedicated UI tab for AI suggestions in helpdesk tickets * Auto-creation of sales orders with matched products The integration streamlines the process of converting customer support requests into sales opportunities. --- helpdesk_sale_order_ai/__init__.py | 35 + helpdesk_sale_order_ai/__manifest__.py | 6 +- helpdesk_sale_order_ai/data/config.json | 7 + .../migrations/1.0/__init__.py | 2 + .../migrations/1.0/post-migration.py | 104 +++ .../migrations/15.0.1.0/post-migrate.py | 111 +++ .../migrations/15.0.1.0/pre-migrate.py | 43 ++ helpdesk_sale_order_ai/migrations/__init__.py | 2 + helpdesk_sale_order_ai/models/__init__.py | 7 + .../models/ai_prompt_template.py | 115 +++ .../models/helpdesk_team.py | 62 +- .../models/helpdesk_ticket.py | 717 +++++++++++++++--- .../models/res_config_settings.py | 111 +++ helpdesk_sale_order_ai/models/sale_order.py | 29 + .../security/ir.model.access.csv | 3 + helpdesk_sale_order_ai/views/__init__.py | 4 + .../views/ai_prompt_template_views.xml | 102 +++ .../views/helpdesk_team_views.xml | 6 +- .../views/helpdesk_ticket_views.xml | 21 + .../views/res_config_settings_views.xml | 16 + helpdesk_sale_order_ai/wizards/__init__.py | 3 + openwebui_connector/__init__.py | 3 + openwebui_connector/__manifest__.py | 30 + .../data/ai_prompt_template_data.xml | 49 ++ .../migrations/1.0/__init__.py | 2 + .../migrations/1.0/post-migration.py | 56 ++ .../migrations/1.0/pre-migration.py | 66 ++ .../migrations/18.0.1.0.0/__init__.py | 0 .../migrations/18.0.1.0.0/post-migration.py | 74 ++ .../migrations/18.0.1.0.0/pre-migration.py | 54 ++ openwebui_connector/migrations/__init__.py | 2 + openwebui_connector/models/__init__.py | 5 + .../models/ai_prompt_template.py | 187 +++++ .../models/openwebui_client.py | 359 +++++++++ .../models/res_config_settings.py | 382 ++++++++++ .../security/ir.model.access.csv | 5 + .../views/ai_prompt_template_views.xml | 109 +++ .../views/res_config_settings_views.xml | 68 ++ 38 files changed, 2839 insertions(+), 118 deletions(-) create mode 100644 helpdesk_sale_order_ai/data/config.json create mode 100644 helpdesk_sale_order_ai/migrations/1.0/__init__.py create mode 100644 helpdesk_sale_order_ai/migrations/1.0/post-migration.py create mode 100644 helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py create mode 100644 helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py create mode 100644 helpdesk_sale_order_ai/migrations/__init__.py create mode 100644 helpdesk_sale_order_ai/models/ai_prompt_template.py create mode 100644 helpdesk_sale_order_ai/models/res_config_settings.py create mode 100644 helpdesk_sale_order_ai/models/sale_order.py create mode 100644 helpdesk_sale_order_ai/security/ir.model.access.csv create mode 100644 helpdesk_sale_order_ai/views/__init__.py create mode 100644 helpdesk_sale_order_ai/views/ai_prompt_template_views.xml create mode 100644 helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml create mode 100644 helpdesk_sale_order_ai/views/res_config_settings_views.xml create mode 100644 helpdesk_sale_order_ai/wizards/__init__.py create mode 100644 openwebui_connector/__init__.py create mode 100644 openwebui_connector/__manifest__.py create mode 100644 openwebui_connector/data/ai_prompt_template_data.xml create mode 100644 openwebui_connector/migrations/1.0/__init__.py create mode 100644 openwebui_connector/migrations/1.0/post-migration.py create mode 100644 openwebui_connector/migrations/1.0/pre-migration.py create mode 100644 openwebui_connector/migrations/18.0.1.0.0/__init__.py create mode 100644 openwebui_connector/migrations/18.0.1.0.0/post-migration.py create mode 100644 openwebui_connector/migrations/18.0.1.0.0/pre-migration.py create mode 100644 openwebui_connector/migrations/__init__.py create mode 100644 openwebui_connector/models/__init__.py create mode 100644 openwebui_connector/models/ai_prompt_template.py create mode 100644 openwebui_connector/models/openwebui_client.py create mode 100644 openwebui_connector/models/res_config_settings.py create mode 100644 openwebui_connector/security/ir.model.access.csv create mode 100644 openwebui_connector/views/ai_prompt_template_views.xml create mode 100644 openwebui_connector/views/res_config_settings_views.xml diff --git a/helpdesk_sale_order_ai/__init__.py b/helpdesk_sale_order_ai/__init__.py index cde864b..5e0a42f 100644 --- a/helpdesk_sale_order_ai/__init__.py +++ b/helpdesk_sale_order_ai/__init__.py @@ -1,3 +1,38 @@ # -*- coding: utf-8 -*- from . import models +from . import views + +def post_init_hook(cr, registry=None): + """Initialize default AI prompt template after module installation + + Note: This function can be called with either one argument (env) or two arguments (cr, registry) + to accommodate different Odoo hook calling conventions. + """ + import logging + _logger = logging.getLogger(__name__) + _logger.info("=== START: post_init_hook ====") + + try: + from odoo import api, SUPERUSER_ID + + # Handle different calling conventions + if hasattr(cr, 'env'): # cr is actually an env + env = cr + _logger.info("Using provided environment") + elif registry: # We have both cr and registry + env = api.Environment(cr, SUPERUSER_ID, {}) + _logger.info("Created environment from cursor and registry") + else: + _logger.warning("Cannot create environment, skipping template creation") + return + + _logger.info("Calling _ensure_default_template") + env['openwebui.prompt.template']._ensure_default_template('helpdesk') + _logger.info("Default template ensured successfully") + _logger.info("=== END: post_init_hook ====") + except Exception as e: + _logger.error(f"ERROR in post_init_hook: {str(e)}") + _logger.exception("Exception traceback:") + # Don't raise the exception to prevent installation failure + return diff --git a/helpdesk_sale_order_ai/__manifest__.py b/helpdesk_sale_order_ai/__manifest__.py index 5be0551..946ff7c 100644 --- a/helpdesk_sale_order_ai/__manifest__.py +++ b/helpdesk_sale_order_ai/__manifest__.py @@ -16,12 +16,16 @@ 'maintainer': 'it@bemade.org', 'depends': [ 'helpdesk_sale_order', - 'openai_connector', # Supposant qu'un module de connexion OpenAI existe + 'openwebui_connector', ], 'data': [ + 'security/ir.model.access.csv', 'views/helpdesk_team_views.xml', 'views/helpdesk_ticket_views.xml', + 'views/res_config_settings_views.xml', + 'views/ai_prompt_template_views.xml', ], 'installable': True, 'application': False, + 'post_init_hook': 'post_init_hook', } diff --git a/helpdesk_sale_order_ai/data/config.json b/helpdesk_sale_order_ai/data/config.json new file mode 100644 index 0000000..a4d56ae --- /dev/null +++ b/helpdesk_sale_order_ai/data/config.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000..7af1d05 --- /dev/null +++ b/helpdesk_sale_order_ai/migrations/1.0/__init__.py @@ -0,0 +1,2 @@ +# -*- 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 new file mode 100644 index 0000000..cd81831 --- /dev/null +++ b/helpdesk_sale_order_ai/migrations/1.0/post-migration.py @@ -0,0 +1,104 @@ +# -*- 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 new file mode 100644 index 0000000..39d3f45 --- /dev/null +++ b/helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py @@ -0,0 +1,111 @@ +# -*- 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 new file mode 100644 index 0000000..ed0ca44 --- /dev/null +++ b/helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py @@ -0,0 +1,43 @@ +# -*- 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 new file mode 100644 index 0000000..7af1d05 --- /dev/null +++ b/helpdesk_sale_order_ai/migrations/__init__.py @@ -0,0 +1,2 @@ +# -*- 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 1be4296..5611a47 100644 --- a/helpdesk_sale_order_ai/models/__init__.py +++ b/helpdesk_sale_order_ai/models/__init__.py @@ -2,3 +2,10 @@ from . import helpdesk_ticket from . import helpdesk_team +from . import sale_order +# Using centralized openwebui.openwebui.client model from openwebui_connector +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_prompt_template.py b/helpdesk_sale_order_ai/models/ai_prompt_template.py new file mode 100644 index 0000000..c845c04 --- /dev/null +++ b/helpdesk_sale_order_ai/models/ai_prompt_template.py @@ -0,0 +1,115 @@ +# -*- 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 7cc6df0..918ed3a 100644 --- a/helpdesk_sale_order_ai/models/helpdesk_team.py +++ b/helpdesk_sale_order_ai/models/helpdesk_team.py @@ -7,19 +7,59 @@ class HelpdeskTeam(models.Model): 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.', + help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions for this team. This overrides the global setting.', default=False, ) - ai_prompt_template = fields.Text( + ai_prompt_template_id = fields.Many2one( + 'openwebui.prompt.template', + domain="[('template_type', '=', 'helpdesk')]", string='AI Prompt Template', - help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.', - default="""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 -""" + help='Template for the prompt sent to the AI. If empty, the global template will be used.', ) + + def _get_use_ai_sale_orders(self): + """Get whether to use AI sale orders, considering both team and global settings""" + self.ensure_one() + # If team has specific setting, use that + 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') + 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 + + # 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 + + # Fallback to default template + default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk') + return default_template.content if default_template else """ + 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 + """ diff --git a/helpdesk_sale_order_ai/models/helpdesk_ticket.py b/helpdesk_sale_order_ai/models/helpdesk_ticket.py index 7757810..482d861 100644 --- a/helpdesk_sale_order_ai/models/helpdesk_ticket.py +++ b/helpdesk_sale_order_ai/models/helpdesk_ticket.py @@ -3,6 +3,7 @@ from odoo import models, fields, api, _ from odoo.exceptions import UserError import logging import json +import re _logger = logging.getLogger(__name__) @@ -10,59 +11,185 @@ _logger = logging.getLogger(__name__) class HelpdeskTicket(models.Model): _inherit = 'helpdesk.ticket' - # Utiliser un champ calculé au lieu d'un champ simple avec onchange + # Computed field to determine if team uses AI sale orders team_use_ai_sale_orders = fields.Boolean( - string='Team Uses AI for Sale Orders', + string='Team Uses AI Sale Orders', compute='_compute_team_use_ai_sale_orders', + readonly=True, ) - @api.depends('team_id') - def _compute_team_use_ai_sale_orders(self): - for ticket in self: - ticket.team_use_ai_sale_orders = False - if ticket.team_id: - # Vérifier si le champ existe sur l'équipe - team = self.env['helpdesk.team'].sudo().browse(ticket.team_id.id) - if hasattr(team, 'use_ai_sale_orders'): - ticket.team_use_ai_sale_orders = team.use_ai_sale_orders - 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 for generating sale order if enabled""" + """Override to use AI if enabled""" self.ensure_one() - # Check if the team allows sale orders - if not self.team_use_sale_orders: - raise UserError(_("You cannot create a sale order from this ticket because your team does not allow it.")) - - # Vérifier directement sur l'équipe si l'IA est activée - use_ai = False - if self.team_id and hasattr(self.team_id, 'use_ai_sale_orders'): - use_ai = self.team_id.use_ai_sale_orders - - # If AI is enabled for this team, use it to generate the sale order - if use_ai: + # 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 from the parent module + # 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() - # Generate AI suggestions if not already done - if not self.ai_generated_products: - self._generate_ai_product_suggestions() + _logger.info("Starting AI conversion to sale order for ticket %s", self.id) - # Create the sale order with AI-suggested products - so_vals = self._generate_ai_so_values() - sale_order = self.env['sale.order'].create([so_vals]) + # Always generate fresh AI suggestions + _logger.info("Generating fresh AI suggestions for ticket %s", self.id) + result = self._generate_ai_product_suggestions() + _logger.info("AI suggestion generation result for ticket %s: %s", self.id, result) + + # Get base values for sale order (partner, pricelist, etc.) + partner_id = self.partner_id.id + partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice'] + partner_shipping_id = self.partner_id.address_get(['delivery'])['delivery'] + + # Parse AI suggestions to get order lines and sale order fields + ai_data = {'order_lines': [], 'sale_order_fields': {}} + if self.ai_generated_products: + _logger.info("AI suggestions found for ticket %s, parsing them now: %s", self.id, self.ai_generated_products[:200]) + ai_data = self._parse_ai_product_suggestions() + _logger.info("Parsed AI data: %s", ai_data) + + # Prepare sale order values + so_values = { + 'partner_id': partner_id, + 'partner_invoice_id': partner_invoice_id, + 'partner_shipping_id': partner_shipping_id, + 'ticket_id': self.id, + 'origin': self.name, + 'note': self.description, + } + + # Add AI-extracted fields to sale order values if available + if ai_data.get('sale_order_fields'): + so_fields = ai_data['sale_order_fields'] + + # Client order reference (PO number) + if so_fields.get('client_order_ref'): + so_values['client_order_ref'] = so_fields['client_order_ref'] + _logger.info(f"Setting client_order_ref to: {so_fields['client_order_ref']}") + + # Order date + if so_fields.get('date_order'): + try: + # Validate date format + from datetime import datetime + date_order = datetime.strptime(so_fields['date_order'], '%Y-%m-%d') + so_values['date_order'] = date_order + _logger.info(f"Setting date_order to: {so_fields['date_order']}") + except (ValueError, TypeError) as e: + _logger.warning(f"Invalid date_order format: {so_fields['date_order']}, error: {e}") + + # Commitment date (delivery date) + if so_fields.get('commitment_date'): + try: + # Validate date format + from datetime import datetime + commitment_date = datetime.strptime(so_fields['commitment_date'], '%Y-%m-%d') + so_values['commitment_date'] = commitment_date + _logger.info(f"Setting commitment_date to: {so_fields['commitment_date']}") + except (ValueError, TypeError) as e: + _logger.warning(f"Invalid commitment_date format: {so_fields['commitment_date']}, error: {e}") + + # Note (special instructions) + if so_fields.get('note'): + # Append to existing note if any + existing_note = so_values.get('note', '') + if existing_note: + so_values['note'] = f"{existing_note}\n\n{so_fields['note']}" + else: + so_values['note'] = so_fields['note'] + _logger.info(f"Setting note to: {so_values['note'][:100]}...") + + # Payment terms + if so_fields.get('payment_term_id'): + # Try to find matching payment term + payment_term_name = so_fields['payment_term_id'] + payment_term = self.env['account.payment.term'].search( + ['|', ('name', '=', payment_term_name), ('name', 'ilike', payment_term_name)], limit=1) + if payment_term: + so_values['payment_term_id'] = payment_term.id + _logger.info(f"Setting payment_term_id to: {payment_term.name} (ID: {payment_term.id})") + else: + _logger.warning(f"Payment term not found: {payment_term_name}") + + # Create the sale order + sale_order = self.env['sale.order'].create(so_values) + _logger.info(f"Created sale order with ID {sale_order.id}") + + # Add order lines to the sale order + order_lines = ai_data.get('order_lines', []) + _logger.info("Adding %d order lines to sale order %s", len(order_lines), sale_order.id) + + for line in order_lines: + # Each line is a tuple (0, 0, values_dict) + # Extract the values dict + line_values = line[2] + _logger.info("Creating order line with values: %s", line_values) + # Create a new order line that will trigger price computation + # Include the price_unit from the parsed data if available + initial_values = { + 'order_id': sale_order.id, + 'product_id': line_values.get('product_id'), + 'product_uom_qty': line_values.get('product_uom_qty'), + 'name': line_values.get('name'), + } + + # Get the price that was calculated in _create_product_order_line + calculated_price = line_values.get('price_unit') + if calculated_price is not None: + _logger.info(f"Using pre-calculated price: {calculated_price} for product ID: {line_values.get('product_id')}") + initial_values['price_unit'] = calculated_price + + order_line = self.env['sale.order.line'].new(initial_values) + + # Trigger standard Odoo onchange to compute prices + try: + # This is the main onchange that should set the price based on product and pricelist + order_line._onchange_product_id() + + # Log the computed price for debugging + _logger.info(f"Standard Odoo price computation: {order_line.price_unit} for product {order_line.product_id.name}") + + # If price is still 0 and product has a list price, use that as fallback + if order_line.price_unit == 0 and order_line.product_id.list_price > 0: + order_line.price_unit = order_line.product_id.list_price + _logger.info(f"Price was 0, using product list price: {order_line.price_unit}") + except Exception as e: + _logger.error(f"Error in standard price computation: {str(e)}") + # Continue with creation even if price computation fails + + # Create a clean dict with only the necessary values + order_line_values = { + 'order_id': sale_order.id, + 'product_id': order_line.product_id.id, + 'product_uom_qty': order_line.product_uom_qty, + 'name': order_line.name, + 'price_unit': order_line.price_unit, + } + + # Add product_uom if it exists + if order_line.product_uom: + order_line_values['product_uom'] = order_line.product_uom.id + + # Create the actual order line with computed prices + self.env['sale.order.line'].create(order_line_values) # Link the sale order to the ticket self.write({ @@ -75,107 +202,491 @@ class HelpdeskTicket(models.Model): 'name': _('Sale Order'), 'res_model': 'sale.order', 'res_id': sale_order.id, - 'view_mode': 'form', - 'context': {'create': False}, + 'view_mode': 'form,list', + 'context': self.env.context, } def _generate_ai_product_suggestions(self): - """Use AI to generate product suggestions based on ticket description""" + """Use AI to generate product suggestions based on ticket description, chatter messages and attachments""" self.ensure_one() - # Skip if no description - if not self.description: + _logger.info("Generating AI product suggestions for ticket %s", self.id) + + # Get the ticket description + description = self.description or "" + + # If description is empty, try to use the name + if not description.strip(): + description = self.name or "" + + # Get chatter messages + chatter_messages = "" + if self.message_ids: + for message in self.message_ids: + if message.body and not message.is_internal: + # Extract text from HTML + body_text = re.sub(r'<[^>]+>', ' ', message.body) + chatter_messages += f"Message from {message.author_id.name or 'Unknown'}: {body_text}\n\n" + + # Get attachments + attachments_info = "" + attachment_contents = "" + if self.message_ids: + for message in self.message_ids: + if message.attachment_ids: + for attachment in message.attachment_ids: + attachments_info += f"Attachment: {attachment.name} ({attachment.mimetype})\n" + + # Extract text from PDF attachments + if attachment.mimetype == 'application/pdf' and attachment.datas: + try: + import base64 + import io + + # Try to use PyPDF2 if available + try: + from PyPDF2 import PdfReader + + pdf_data = base64.b64decode(attachment.datas) + pdf_file = io.BytesIO(pdf_data) + pdf_reader = PdfReader(pdf_file) + + pdf_text = "" + for page_num in range(len(pdf_reader.pages)): # Process all pages + page = pdf_reader.pages[page_num] + pdf_text += page.extract_text() + "\n" + + attachment_contents += f"Content from {attachment.name}:\n{pdf_text}\n\n" # Include full text + except ImportError: + _logger.warning("PyPDF2 not available, skipping PDF text extraction") + except Exception as e: + _logger.error(f"Error extracting text from PDF: {str(e)}") + + # If everything is empty, show error + if not description.strip() and not chatter_messages.strip() and not attachment_contents.strip(): + _logger.error("No content available for AI analysis") return False + # Create the prompt for the AI + prompt = f"""You are an expert sales assistant for a pneumatic automation company. + Your task is to analyze the customer request and suggest appropriate products or services. + + Customer Request: + {description} + + Chatter Messages: + {chatter_messages} + + Attachments Information: + {attachments_info} + + Attachment Contents: + {attachment_contents} + + Based on this information, please perform two tasks: + + 1. Map the following Odoo sale order fields from the information provided: + - client_order_ref: Customer's reference/PO number + - date_order: Order date (in YYYY-MM-DD format) + - commitment_date: Delivery date (in YYYY-MM-DD format) + - note: Any special instructions or notes + - payment_term_id: Payment terms (e.g., "Net 30", "2% 10 Net 30") + + 2. Suggest products or services that would meet the customer's needs. + + IMPORTANT: Your response MUST be in valid JSON format as shown below. Do not include any explanatory text outside the JSON structure. + + ```json + {{ + "sale_order_fields": {{ + "client_order_ref": "Customer PO number", + "date_order": "YYYY-MM-DD", + "commitment_date": "YYYY-MM-DD", + "note": "Special instructions", + "payment_term_id": "Payment terms" + }}, + "products": [ + {{ "name": "Product Name", "quantity": 2, "description": "Product description" }}, + {{ "name": "Another Product", "quantity": 1, "description": "Another description" }} + ] + }} + ``` + + Only include fields and products that are clearly identified from the provided information. + If you're not sure about a field or product, leave it blank or don't include it. + If you cannot identify any products, return an empty products array but still include any sale_order_fields you can identify. + + Remember: Your entire response must be valid JSON wrapped in code blocks. No other text. + """ + try: - # Prepare the prompt using the template from the team - prompt = self.team_id.ai_prompt_template.format( - description=self.description, - customer=self.partner_id.name or 'Unknown', - ) + # Get the OpenWebUI client + client = self.env['openai.openwebui.client'] - # Call the AI service (assuming an OpenAI connector module exists) - ai_service = self.env['openai.service'].sudo() - response = ai_service.generate_completion(prompt) + # Create the messages for the AI + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt} + ] - # Store the AI response - self.ai_generated_products = response + # Call the OpenWebUI API + _logger.info("Calling OpenWebUI API for ticket %s", self.id) + response = client.chat_completion(messages) - return True + 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: %s", str(e)) + _logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, str(e)) + import traceback + _logger.error("Traceback: %s", traceback.format_exc()) return False + # Note: This method is kept for compatibility but is no longer used + # The _ai_convert_to_sale_order method now creates the sale order directly def _generate_ai_so_values(self): """Generate sale order values with AI-suggested products""" # Start with the base SO values from the parent method - so_vals = self._generate_so_values() - - # Parse AI suggestions and add as order lines - if self.ai_generated_products: - order_lines = self._parse_ai_product_suggestions() - if order_lines: - so_vals['order_line'] = order_lines - - return so_vals + return self._generate_so_values() def _parse_ai_product_suggestions(self): - """Parse the AI-generated product suggestions into sale order lines""" - order_lines = [] + """Parse the AI-generated product suggestions into sale order lines and fields""" + result = { + 'order_lines': [], + 'sale_order_fields': {} + } if not self.ai_generated_products: - return order_lines + _logger.warning("No AI generated products found for ticket %s", self.id) + return result['order_lines'] - # Simple parsing of the AI response - # Format expected: Product/Service Name | Quantity | Description - lines = self.ai_generated_products.strip().split('\n') + # Log the AI response for debugging + _logger.info("Parsing AI product suggestions for ticket %s: %s", self.id, self.ai_generated_products[:300]) - for line in lines: - if '|' not in line: - continue - - parts = [part.strip() for part in line.split('|')] - if len(parts) < 2: - continue - - product_name = parts[0] - quantity = 1.0 - description = '' + # 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) - # Try to parse quantity - if len(parts) > 1: + if not json_matches: + _logger.info("No JSON found with pattern 2, trying pattern 3") + json_matches = re.findall(json_pattern3, self.ai_generated_products) + + if json_matches: + # Try to parse the JSON + try: + # Clean up the JSON string before parsing + json_str = json_matches[0] + # Remove any trailing commas before closing brackets (common JSON error) + json_str = re.sub(r',\s*([\]\}])', r'\1', json_str) + + json_data = json.loads(json_str) + _logger.info(f"Successfully parsed JSON data: {json_data}") + + # Extract sale order fields + if 'sale_order_fields' in json_data: + result['sale_order_fields'] = json_data['sale_order_fields'] + _logger.info(f"Extracted sale order fields: {result['sale_order_fields']}") + # Direct fields at root level (fallback) + elif any(key in json_data for key in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']): + so_fields = {} + for field in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']: + if field in json_data: + so_fields[field] = json_data[field] + result['sale_order_fields'] = so_fields + _logger.info(f"Extracted sale order fields from root level: {result['sale_order_fields']}") + + # Extract products - check multiple possible keys + product_key = None + for key in ['products', 'product_suggestions', 'order_lines', 'items']: + if key in json_data and isinstance(json_data[key], list): + product_key = key + break + + if product_key: + for product in json_data[product_key]: + if not isinstance(product, dict): + continue + + product_name = product.get('name') + if not product_name: + continue + + quantity = product.get('quantity', 1.0) + try: + quantity = float(quantity) + except (ValueError, TypeError): + quantity = 1.0 + + description = product.get('description', '') + + _logger.info(f"Processing product from JSON: {product_name}, qty={quantity}, desc={description}") + + order_line = self._create_product_order_line(product_name, quantity, description) + if order_line: + result['order_lines'].append(order_line) + + _logger.info(f"Parsed {len(result['order_lines'])} order lines from JSON") + return result + except json.JSONDecodeError as e: + _logger.error(f"Failed to parse JSON: {e}") + + # Try to extract just the sale order fields using regex as a last resort try: - quantity = float(parts[1]) - except ValueError: - quantity = 1.0 + # 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") - # Get description if available - if len(parts) > 2: - description = parts[2] + # 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) - # Search for matching product + for line in processed_lines: + _logger.info(f"Processing line: {line}") + + # Try to extract quantity, product name, part number, and description + # Format examples: + # - 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency + # - 1x Preventive Maintenance Service: Annual service package + # - 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI + + # Pattern for the format specified in the prompt template + detailed_pattern = r"(\d+)x\s+([^:]+?)(?:\s+([A-Z0-9][A-Z0-9-]+))?\s*:?\s*(.*)" + match = re.search(detailed_pattern, line, re.IGNORECASE) + + if match: + quantity = float(match.group(1)) + product_name = match.group(2).strip() + part_number = match.group(3) if match.group(3) else '' + specs = match.group(4).strip() if match.group(4) else '' + + # Combine part number with product name if available + if part_number: + full_product_name = f"{product_name} {part_number}" + else: + full_product_name = product_name + + # Use specifications as description if available + description = specs if specs else product_name + + _logger.info(f"Matched detailed pattern: qty={quantity}, product={full_product_name}, desc={description}") + + order_line = self._create_product_order_line(full_product_name, quantity, description) + if order_line: + order_lines.append(order_line) + continue + + # Try other common patterns if the detailed pattern didn't match + qty_patterns = [ + r"(\d+(?:\.\d+)?)\s*x\s*([^\d\n]+)", # "2x Product Name" or "2.5x Product Name" + r"([^\d\n]+)\s*\(\s*qty\s*:\s*(\d+(?:\.\d+)?)\s*\)", # "Product Name (qty: 3)" + r"([^\d\n]+)\s*-\s*(\d+(?:\.\d+)?)\s*units?", # "Product Name - 4 units" + r"([^\d\n]+)\s*:\s*(\d+(?:\.\d+)?)", # "Product Name: 2" + r"quantity\s*:\s*(\d+(?:\.\d+)?)\s*,?\s*([^,]+)", # "Quantity: 2, Product Name" + ] + + product_name = None + quantity = 1.0 + description = "" + + for pattern in qty_patterns: + match = re.search(pattern, line, re.IGNORECASE) + if match: + if pattern == qty_patterns[0]: # "2x Product Name" + try: + quantity = float(match.group(1)) + product_name = match.group(2).strip() + except (ValueError, IndexError): + continue + else: # Other patterns + try: + product_name = match.group(1).strip() + quantity = float(match.group(2)) + except (ValueError, IndexError): + continue + + # Try to extract description after the product name + desc_match = re.search(r"[^:]+:(.+)$", line) + if desc_match: + description = desc_match.group(1).strip() + + _logger.info(f"Matched pattern {pattern}: qty={quantity}, product={product_name}, desc={description}") + break + + # If no pattern matched, use the whole line as product name + if not product_name: + # Check if there's a colon that might separate product name from description + if ':' in line: + parts = line.split(':', 1) + product_name = parts[0].strip() + description = parts[1].strip() if len(parts) > 1 else '' + else: + product_name = line + description = '' + + _logger.info(f"No pattern match, using line as product: {product_name}, desc={description}") + + # Add the order line + order_line = self._create_product_order_line(product_name, quantity, description) + if order_line: + order_lines.append(order_line) + + result['order_lines'] = order_lines + _logger.info(f"Parsed {len(order_lines)} order lines from AI suggestions") + return result + + def _create_product_order_line(self, product_name, quantity, description=""): + """Create a sale order line for a product""" + if not product_name: + return False + + # Search for matching product - try exact match first + product = self.env['product.product'].search([ + ('name', '=', product_name), + ('sale_ok', '=', True) + ], limit=1) + + # If no exact match, try partial match + if not product: product = self.env['product.product'].search([ ('name', 'ilike', product_name), ('sale_ok', '=', True) ], limit=1) - - if not product: - # If no product found, create a service product - product = self.env['product.product'].create({ - 'name': product_name, - 'type': 'service', - 'sale_ok': True, - 'purchase_ok': False, - 'list_price': 0.0, - }) - - # Create order line - order_line = (0, 0, { - 'product_id': product.id, - 'product_uom_qty': quantity, - 'name': description or product.name, - }) - - order_lines.append(order_line) - return order_lines + # If still no product found, try matching by default_code (SKU/part number) + if not product and any(c.isdigit() for c in product_name): # Check if product name contains numbers (likely a part number) + # Extract potential part numbers + part_numbers = re.findall(r'[A-Z0-9][A-Z0-9-]+', product_name) + for part in part_numbers: + product = self.env['product.product'].search([ + ('default_code', '=', part), + ('sale_ok', '=', True) + ], limit=1) + if product: + break + + # If no product found, log it and return False + if not product: + _logger.info(f"No matching product found for: {product_name}") + return False + + # Create order line with price information + line_values = { + 'product_id': product.id, + 'product_uom_qty': quantity, + 'name': description or product.name, + } + + # We don't need to set the price here - Odoo will handle this automatically + # when the sale order line is created with the product + # Just log the product's list price for debugging + _logger.info(f"Product {product.name} (ID: {product.id}) has list_price: {product.list_price}") + + # We intentionally don't set price_unit here to let Odoo's standard mechanisms handle it + + return (0, 0, line_values) diff --git a/helpdesk_sale_order_ai/models/res_config_settings.py b/helpdesk_sale_order_ai/models/res_config_settings.py new file mode 100644 index 0000000..296a0b5 --- /dev/null +++ b/helpdesk_sale_order_ai/models/res_config_settings.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +import logging + +_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', + ) + + # 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 + ) + + @api.model + def get_values(self): + """Get values for the settings form""" + res = super(ResConfigSettings, self).get_values() + + # Get the template ID from the system parameter + 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_id_int = int(template_id) if isinstance(template_id, str) else template_id + res['helpdesk_ai_prompt_template_id'] = template_id_int + except (ValueError, TypeError): + _logger.error(f"Invalid template ID in system parameter: {template_id}") + + return res + + def set_values(self): + """Set values from the settings form""" + super(ResConfigSettings, self).set_values() + + # Save the template ID to the system parameter + 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): + """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('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) + 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('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 new file mode 100644 index 0000000..96eebf3 --- /dev/null +++ b/helpdesk_sale_order_ai/models/sale_order.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + # Add missing_product_count field to avoid view errors + # This is needed because some views expect this field + missing_product_count = fields.Integer( + string='Missing Products Count', + default=0, + help='Number of products that could not be found in the database' + ) + + # Add has_missing_products field to avoid view errors + has_missing_products = fields.Boolean( + string='Has Missing Products', + default=False, + help='Whether this sale order has products that could not be found in the database' + ) + + # Add ticket_id field to link sale orders to helpdesk tickets + ticket_id = fields.Many2one( + 'helpdesk.ticket', + string='Helpdesk Ticket', + readonly=True, + copy=False, + help='Helpdesk ticket from which this sale order was created' + ) diff --git a/helpdesk_sale_order_ai/security/ir.model.access.csv b/helpdesk_sale_order_ai/security/ir.model.access.csv new file mode 100644 index 0000000..1fb0b9c --- /dev/null +++ b/helpdesk_sale_order_ai/security/ir.model.access.csv @@ -0,0 +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 diff --git a/helpdesk_sale_order_ai/views/__init__.py b/helpdesk_sale_order_ai/views/__init__.py new file mode 100644 index 0000000..d2f6e16 --- /dev/null +++ b/helpdesk_sale_order_ai/views/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +# These imports are handled differently in Odoo 18 +# XML files are loaded automatically by the manifest diff --git a/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml b/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml new file mode 100644 index 0000000..8aee278 --- /dev/null +++ b/helpdesk_sale_order_ai/views/ai_prompt_template_views.xml @@ -0,0 +1,102 @@ + + + + + openwebui.prompt.template.form + openwebui.prompt.template + +
+ + +
+ + +
+
+

+
+ + + + + + + + + +
+ Use placeholders like {description}, {customer}, etc. to include ticket information in the prompt. +
+ +
+
+
+ +
+
+ + + + openwebui.prompt.template.list + openwebui.prompt.template + + + + + + + + + + + + + + openwebui.prompt.template.search + openwebui.prompt.template + + + + + + + + + + + + + AI Prompt Templates + openwebui.prompt.template + [('template_type', '=', 'helpdesk')] + {'default_template_type': 'helpdesk'} + list,form + +

+ Create a new AI prompt template +

+

+ Define templates for AI prompts used in helpdesk tickets. +

+
+
+ + + +
diff --git a/helpdesk_sale_order_ai/views/helpdesk_team_views.xml b/helpdesk_sale_order_ai/views/helpdesk_team_views.xml index 23708a2..0e69b2c 100644 --- a/helpdesk_sale_order_ai/views/helpdesk_team_views.xml +++ b/helpdesk_sale_order_ai/views/helpdesk_team_views.xml @@ -5,9 +5,9 @@ helpdesk.team - - - + + + diff --git a/helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml b/helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml new file mode 100644 index 0000000..b130e9a --- /dev/null +++ b/helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml @@ -0,0 +1,21 @@ + + + + helpdesk.ticket.form.view.inherit.ai + helpdesk.ticket + + + + + + + + + + + + + + + + diff --git a/helpdesk_sale_order_ai/views/res_config_settings_views.xml b/helpdesk_sale_order_ai/views/res_config_settings_views.xml new file mode 100644 index 0000000..09e75f5 --- /dev/null +++ b/helpdesk_sale_order_ai/views/res_config_settings_views.xml @@ -0,0 +1,16 @@ + + + + + res.config.settings.view.form.inherit.helpdesk.sale.order.ai + res.config.settings + + + +
+ + + +
+
+
diff --git a/helpdesk_sale_order_ai/wizards/__init__.py b/helpdesk_sale_order_ai/wizards/__init__.py new file mode 100644 index 0000000..43d33cd --- /dev/null +++ b/helpdesk_sale_order_ai/wizards/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import openwebui_config_wizard diff --git a/openwebui_connector/__init__.py b/openwebui_connector/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/openwebui_connector/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/openwebui_connector/__manifest__.py b/openwebui_connector/__manifest__.py new file mode 100644 index 0000000..0eb250c --- /dev/null +++ b/openwebui_connector/__manifest__.py @@ -0,0 +1,30 @@ +# -*- 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 new file mode 100644 index 0000000..b07b0ca --- /dev/null +++ b/openwebui_connector/data/ai_prompt_template_data.xml @@ -0,0 +1,49 @@ + + + + + + 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 new file mode 100644 index 0000000..7af1d05 --- /dev/null +++ b/openwebui_connector/migrations/1.0/__init__.py @@ -0,0 +1,2 @@ +# -*- 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 new file mode 100644 index 0000000..03f7e9c --- /dev/null +++ b/openwebui_connector/migrations/1.0/post-migration.py @@ -0,0 +1,56 @@ +# -*- 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 new file mode 100644 index 0000000..04d40b8 --- /dev/null +++ b/openwebui_connector/migrations/1.0/pre-migration.py @@ -0,0 +1,66 @@ +# -*- 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 new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..ba670d8 --- /dev/null +++ b/openwebui_connector/migrations/18.0.1.0.0/post-migration.py @@ -0,0 +1,74 @@ +# -*- 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 new file mode 100644 index 0000000..7260667 --- /dev/null +++ b/openwebui_connector/migrations/18.0.1.0.0/pre-migration.py @@ -0,0 +1,54 @@ +# -*- 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 new file mode 100644 index 0000000..7af1d05 --- /dev/null +++ b/openwebui_connector/migrations/__init__.py @@ -0,0 +1,2 @@ +# -*- 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 new file mode 100644 index 0000000..661d16b --- /dev/null +++ b/openwebui_connector/models/__init__.py @@ -0,0 +1,5 @@ +# -*- 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 new file mode 100644 index 0000000..96e8692 --- /dev/null +++ b/openwebui_connector/models/ai_prompt_template.py @@ -0,0 +1,187 @@ +# -*- 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 new file mode 100644 index 0000000..b01dded --- /dev/null +++ b/openwebui_connector/models/openwebui_client.py @@ -0,0 +1,359 @@ +# -*- 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 new file mode 100644 index 0000000..af97b56 --- /dev/null +++ b/openwebui_connector/models/res_config_settings.py @@ -0,0 +1,382 @@ +# -*- 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 new file mode 100644 index 0000000..abf7f47 --- /dev/null +++ b/openwebui_connector/security/ir.model.access.csv @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..927299c --- /dev/null +++ b/openwebui_connector/views/ai_prompt_template_views.xml @@ -0,0 +1,109 @@ + + + + + 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. +
+
+ +
+
+
+
+
+
+ + + + openwebui.prompt.template.list + openwebui.prompt.template + + + + + + + + + + + + + + openwebui.prompt.template.search + openwebui.prompt.template + + + + + + + + + + + + + + + + + AI Prompt Templates + openwebui.prompt.template + list,form + +

+ Create a new AI prompt template +

+

+ Define templates for AI prompts used in various modules. +

+
+
+ + + +
diff --git a/openwebui_connector/views/res_config_settings_views.xml b/openwebui_connector/views/res_config_settings_views.xml new file mode 100644 index 0000000..5580f24 --- /dev/null +++ b/openwebui_connector/views/res_config_settings_views.xml @@ -0,0 +1,68 @@ + + + + res.config.settings.view.form.inherit.openai.connector + res.config.settings + 20 + + + + + + + + API Configuration +
+ Configure your AI API credentials (OpenAI, OpenWebUI, Claude) +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + +
+
+
+
+
+
+
+
+