[REF] Replace openwebui_connector with centralized openwebui_base module

This commit represents a significant architectural improvement:

- Completely replaced openwebui_connector with a more robust openwebui_base module
- Centralized OpenWebUI API integration for better maintainability
- Redesigned helpdesk_sale_order_ai to use the new openwebui_base module
- Added support for multiple OpenWebUI providers and models
- Improved error handling and response parsing
- Added proper template management with openwebui_prompt_template
- Fixed KeyError issues with safe template substitution
- Streamlined API client initialization and usage
This commit is contained in:
mathis 2025-07-28 13:27:29 -04:00
parent ed9dda9bea
commit c5904a51d7
55 changed files with 2738 additions and 2526 deletions

View file

@ -28,7 +28,8 @@ def post_init_hook(cr, registry=None):
return
_logger.info("Calling _ensure_default_template")
env['openwebui.prompt.template']._ensure_default_template('helpdesk')
# Use our bridge model which will call the correct underlying model
env['ai.openwebui.prompt.template']._ensure_default_template('helpdesk')
_logger.info("Default template ensured successfully")
_logger.info("=== END: post_init_hook ====")
except Exception as e:

View file

@ -16,12 +16,13 @@
'maintainer': 'it@bemade.org',
'depends': [
'helpdesk_sale_order',
'openwebui_connector',
'openwebui_base',
],
'data': [
'security/ir.model.access.csv',
'views/helpdesk_team_views.xml',
'views/helpdesk_ticket_views.xml',
'views/helpdesk_ticket_button_views.xml',
'views/res_config_settings_views.xml',
'views/ai_prompt_template_views.xml',
],

View file

@ -1,7 +0,0 @@
{
"OpenWebUI": {
"api_key": "",
"base_url": "https://ai.bemade.org/api",
"model": "anthropic.claude-3-7-sonnet-latest"
}
}

View file

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
# This file is intentionally left empty to make the directory a proper Python package

View file

@ -1,104 +0,0 @@
# -*- coding: utf-8 -*-
# This migration script will run after the module update
# It will ensure the prompt templates are properly set up
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Ensure prompt templates are properly set up after migration
"""
# Check if we have any prompt templates
cr.execute("""
SELECT COUNT(*) FROM helpdesk_ai_prompt_template
""")
template_count = cr.fetchone()[0]
if template_count == 0:
# Create a default template if none exists
_logger.info("No prompt templates found, creating default template")
default_template_name = "Default Sales Order Template"
default_template_content = """
You are a sales order assistant. Your task is to analyze the customer's request and suggest products or services that would meet their needs.
Please provide a list of products or services in the following format:
1. Product Name | Quantity | Description
2. Product Name | Quantity | Description
If you're not sure about a specific product, suggest a generic service item with a description of what it should accomplish.
"""
# Insert the default template
cr.execute("""
INSERT INTO helpdesk_ai_prompt_template (name, content, create_date, write_date)
VALUES (%s, %s, now(), now())
RETURNING id
""", (default_template_name, default_template_content))
template_id = cr.fetchone()[0]
# Set this as the default template
cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (str(template_id),))
# Log the migration
cr.execute("""
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
'Post-migration: Created default prompt template with ID ' || %s,
'/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 50, 'migrate')
""", (str(template_id),))
else:
# Check if we have a default template set
cr.execute("""
SELECT value FROM ir_config_parameter
WHERE key = 'helpdesk_sale_order_ai.default_prompt_template_id'
""")
default_template_id = cr.fetchone()
if not default_template_id:
# Get the first template and set it as default
cr.execute("""
SELECT id FROM helpdesk_ai_prompt_template
ORDER BY id ASC
LIMIT 1
""")
template_id = cr.fetchone()[0]
# Set this as the default template
cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (str(template_id),))
# Log the migration
cr.execute("""
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
'Post-migration: Set existing template with ID ' || %s || ' as default',
'/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 75, 'migrate')
""", (str(template_id),))
else:
_logger.info(f"Default prompt template already set: {default_template_id[0]}")
# Ensure the system parameters are properly set up
cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('openai.base_url', 'https://ai.bemade.org/api', now(), now())
ON CONFLICT (key) DO NOTHING
""")
cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('openai.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
ON CONFLICT (key) DO NOTHING
""")

View file

@ -1,111 +0,0 @@
# -*- coding: utf-8 -*-
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Migrate helpdesk.ai.prompt.template records to openwebui.prompt.template
"""
if not version:
return
_logger.info("Starting migration of AI prompt templates")
# Check if the old model exists
cr.execute("SELECT 1 FROM ir_model WHERE model = 'helpdesk.ai.prompt.template'")
if not cr.fetchone():
_logger.info("No helpdesk.ai.prompt.template model found, skipping migration")
return
# Check if the new model exists
cr.execute("SELECT 1 FROM ir_model WHERE model = 'openwebui.prompt.template'")
if not cr.fetchone():
_logger.warning("openwebui.prompt.template model not found, cannot migrate")
return
# Get all templates from the old model
cr.execute("""
SELECT id, name, content, is_default, active, sequence
FROM helpdesk_ai_prompt_template
""")
old_templates = cr.fetchall()
if not old_templates:
_logger.info("No templates found in helpdesk.ai.prompt.template, skipping migration")
return
_logger.info(f"Found {len(old_templates)} templates to migrate")
# For each template, create a corresponding record in the new model
for template_id, name, content, is_default, active, sequence in old_templates:
# Check if a template with the same name already exists in the new model
cr.execute("""
SELECT id FROM openwebui_prompt_template
WHERE name = %s AND template_type = 'helpdesk'
""", (name,))
existing = cr.fetchone()
if existing:
_logger.info(f"Template '{name}' already exists in openwebui.prompt.template, updating")
cr.execute("""
UPDATE openwebui_prompt_template
SET content = %s, is_default = %s, active = %s, sequence = %s
WHERE id = %s
""", (content, is_default, active, sequence, existing[0]))
else:
_logger.info(f"Creating new template '{name}' in openwebui.prompt.template")
cr.execute("""
INSERT INTO openwebui_prompt_template
(name, content, is_default, active, sequence, template_type, create_date, write_date)
VALUES (%s, %s, %s, %s, %s, 'helpdesk', now(), now())
""", (name, content, is_default, active, sequence))
# Update system parameters that reference the old model
cr.execute("""
SELECT key, value FROM ir_config_parameter
WHERE key LIKE 'helpdesk_sale_order_ai.%_prompt_template_id'
""")
params = cr.fetchall()
for key, value in params:
if value and value.isdigit():
# Get the name of the template
cr.execute("""
SELECT name FROM helpdesk_ai_prompt_template
WHERE id = %s
""", (int(value),))
template_name = cr.fetchone()
if template_name:
# Find the corresponding template in the new model
cr.execute("""
SELECT id FROM openwebui_prompt_template
WHERE name = %s AND template_type = 'helpdesk'
""", (template_name[0],))
new_template_id = cr.fetchone()
if new_template_id:
new_key = key.replace('helpdesk_sale_order_ai', 'openwebui_connector')
_logger.info(f"Updating system parameter {key} to {new_key} with value {new_template_id[0]}")
# Check if the new parameter already exists
cr.execute("""
SELECT id FROM ir_config_parameter
WHERE key = %s
""", (new_key,))
existing_param = cr.fetchone()
if existing_param:
cr.execute("""
UPDATE ir_config_parameter
SET value = %s
WHERE id = %s
""", (str(new_template_id[0]), existing_param[0]))
else:
cr.execute("""
INSERT INTO ir_config_parameter (key, value)
VALUES (%s, %s)
""", (new_key, str(new_template_id[0])))
_logger.info("Migration of AI prompt templates completed")

View file

@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Migrate system parameters from helpdesk OpenWebUI client to centralized OpenAI client
"""
if not version:
return
_logger.info("Starting migration of OpenWebUI configuration parameters")
# Map of old parameter keys to new parameter keys
param_mapping = {
'openwebui.api_key': 'openai.api_key',
'openwebui.base_url': 'openai.base_url',
'openwebui.model': 'openai.model',
}
# For each parameter, check if it exists and migrate it if needed
for old_key, new_key in param_mapping.items():
# Check if the old parameter exists
cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (old_key,))
old_value = cr.fetchone()
if old_value and old_value[0]:
# Check if the new parameter already exists
cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (new_key,))
new_value = cr.fetchone()
if not new_value or not new_value[0]:
# New parameter doesn't exist or is empty, set it to the old value
_logger.info(f"Migrating parameter {old_key} to {new_key} with value {old_value[0]}")
cr.execute("""
INSERT INTO ir_config_parameter (key, value)
VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (new_key, old_value[0]))
_logger.info("Migration of OpenWebUI configuration parameters completed")

View file

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
# This file is intentionally left empty to make the directory a proper Python package

View file

@ -3,9 +3,9 @@
from . import helpdesk_ticket
from . import helpdesk_team
from . import sale_order
# Using centralized openwebui.openwebui.client model from openwebui_connector
from . import ai_openwebui_client # Bridge model for OpenWebUI client
from . import ai_openwebui_prompt_template # Bridge model for OpenWebUI prompt templates
from . import res_config_settings
# Using centralized openwebui.prompt.template model from openwebui_connector
# Import views after all models are loaded
from .. import views

View file

@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
import requests
import json
_logger = logging.getLogger(__name__)
class AIOpenWebUIClient(models.AbstractModel):
"""
Abstract model to provide a bridge between the helpdesk_sale_order_ai module
and the openwebui_base module.
This ensures proper integration with the OpenWebUI API using configuration
from the settings.
"""
_name = "helpdesk_sale_order_ai.openwebui.client"
_description = "OpenWebUI Client"
@api.model
def chat_completion(self, messages, model=None, **kwargs):
"""
Send a chat completion request to the OpenWebUI API
Args:
messages: List of message dictionaries with 'role' and 'content'
model: The model to use for the completion (default: anthropic.claude-3-7-sonnet-latest)
**kwargs: Additional parameters to pass to the OpenWebUI client
Returns:
The response from the OpenWebUI API
"""
# Check if openwebui_base module is installed
module_obj = self.env['ir.module.module']
openwebui_base_module = module_obj.search([('name', '=', 'openwebui_base'), ('state', '=', 'installed')])
if not openwebui_base_module:
_logger.error("The openwebui_base module is not installed. Cannot use AI features.")
return False
try:
# Get configuration from settings
config = self.env['ir.config_parameter'].sudo()
company = self.env.company
# Get provider configuration
provider = company.openwebui_provider_id
if not provider:
_logger.error("No OpenWebUI provider configured for this company")
return False
# Get API key and base URL from provider
api_key = provider.api_key
base_url = provider.base_url
if not api_key or not base_url:
_logger.error("Missing API key or base URL in OpenWebUI provider configuration")
return False
# Get model from company settings or use provided model or default
if not model and company.openwebui_default_model_id:
model = company.openwebui_default_model_id.technical_name
_logger.info(f"Using OpenWebUI API with model={model} and {len(messages)} messages")
# Log first few characters of the prompt for debugging
if messages and len(messages) > 0:
first_content = messages[0].get('content', '')[:100]
_logger.info(f"First message content (truncated): {first_content}...")
# Prepare the request
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}'
}
data = {
'model': model,
'messages': messages,
**kwargs
}
# Make the API call
endpoint = f"{base_url.rstrip('/')}/chat/completions"
_logger.info(f"Sending request to {endpoint}")
response = requests.post(
endpoint,
headers=headers,
json=data,
timeout=60 # Reasonable timeout for AI responses
)
# Check for successful response
response.raise_for_status()
result = response.json()
# Log response summary
if result:
_logger.info(f"Received response from OpenWebUI API: {str(result)[:200]}...")
else:
_logger.warning("Received empty response from OpenWebUI API")
return result
except requests.exceptions.RequestException as e:
_logger.error(f"API request error in chat_completion: {str(e)}")
return False
except json.JSONDecodeError as e:
_logger.error(f"JSON decode error in chat_completion: {str(e)}")
return False
except Exception as e:
_logger.error(f"Unexpected error in chat_completion: {str(e)}")
return False

View file

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class AIOpenWebUIPromptTemplate(models.AbstractModel):
"""
Abstract model to provide a bridge between the old ai.openwebui.prompt.template model
and the new openwebui.prompt.template model from the openwebui_base module.
This ensures backward compatibility while leveraging the new centralized infrastructure.
"""
_name = "ai.openwebui.prompt.template"
_description = "OpenWebUI Prompt Template Bridge"
@api.model
def _ensure_default_template(self, template_type='helpdesk'):
"""Ensure that a default template exists for the given type"""
return self.env['openwebui.prompt.template']._ensure_default_template(template_type)
@api.model
def get_default_template(self, template_type='helpdesk'):
"""Get the default template for the given type"""
return self.env['openwebui.prompt.template'].get_default_template(template_type)

View file

@ -1,115 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class AIPromptTemplate(models.Model):
_name = 'helpdesk.ai.prompt.template'
_description = 'AI Prompt Template'
_rec_name = 'name'
_order = 'sequence, id'
name = fields.Char(
string='Name',
required=True,
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
active = fields.Boolean(
string='Active',
default=True,
)
is_default = fields.Boolean(
string='Is Default',
default=False,
)
content = fields.Text(
string='Template Content',
required=True,
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
)
@api.model
def _ensure_default_template(self):
"""Ensure there's at least one default template"""
import logging
_logger = logging.getLogger(__name__)
_logger.info("=== START: _ensure_default_template ====")
try:
_logger.info("Searching for default template")
default_template = self.search([('is_default', '=', True)], limit=1)
_logger.info(f"Default template search result: {default_template}")
if not default_template:
_logger.info("No default template found, creating one")
# Create a default template if none exists
default_content = """Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
Ticket Description: {description}
Customer: {customer}
Please provide a list of products/services with quantities and descriptions in the following format:
Product/Service Name | Quantity | Description
"""
_logger.info("Creating default template")
new_template = self.create({
'name': 'Default Template',
'content': default_content,
'is_default': True,
})
_logger.info(f"Created default template: {new_template}")
else:
_logger.info(f"Found existing default template: {default_template.name}")
_logger.info("=== END: _ensure_default_template ====")
return True
except Exception as e:
_logger.error(f"ERROR in _ensure_default_template: {str(e)}")
_logger.exception("Exception traceback:")
raise
@api.model_create_multi
def create(self, vals_list):
"""Override create to handle default templates"""
records = super(AIPromptTemplate, self).create(vals_list)
# If any new template is set as default, unset default flag on others
default_templates = records.filtered(lambda r: r.is_default)
if default_templates:
self.search([('id', 'not in', default_templates.ids), ('is_default', '=', True)]).write({'is_default': False})
return records
@api.model
def get_default_template(self):
"""Get the default template"""
import logging
_logger = logging.getLogger(__name__)
_logger.info("=== START: get_default_template ====")
try:
_logger.info("Searching for default template")
default_template = self.search([('is_default', '=', True)], limit=1)
_logger.info(f"Default template search result: {default_template}")
if not default_template:
_logger.info("No default template found, ensuring one exists")
self._ensure_default_template()
default_template = self.search([('is_default', '=', True)], limit=1)
_logger.info(f"Default template after ensure: {default_template}")
_logger.info("=== END: get_default_template ====")
return default_template
except Exception as e:
_logger.error(f"ERROR in get_default_template: {str(e)}")
_logger.exception("Exception traceback:")
return self.browse()
def set_as_default(self):
"""Set this template as the default"""
if self:
# Clear default flag on all other templates
self.search([('id', '!=', self.id)]).write({'is_default': False})
# Set this template as default
self.write({'is_default': True})
return True

View file

@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
import logging
_logger = logging.getLogger(__name__)
class HelpdeskTeam(models.Model):
@ -25,40 +28,40 @@ class HelpdeskTeam(models.Model):
if self.use_ai_sale_orders:
return True
# Otherwise, check global setting
param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'False')
# Otherwise, check global setting (default to True)
param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'True')
return param_value.lower() == 'true' if isinstance(param_value, str) else bool(param_value)
def _get_ai_prompt_template(self):
"""Get the AI prompt template to use for this team"""
self.ensure_one()
# Ensure template model is initialized
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
# If team has a specific template, use it
if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
return self.ai_prompt_template_id.content
try:
# Ensure template model is initialized
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
# Otherwise, get the global default template
IrConfigParam = self.env['ir.config_parameter'].sudo()
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
if template_id:
try:
template = self.env['openwebui.prompt.template'].browse(int(template_id))
if template.exists():
return template.content
except (ValueError, TypeError):
pass
# If team has a specific template, use it
if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
_logger.info(f"Using team-specific prompt template: {self.ai_prompt_template_id.name}")
return self.ai_prompt_template_id.content
# Fallback to default template
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
return default_template.content if default_template else """
# Otherwise, get the global default template
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
if default_template:
_logger.info(f"Using default helpdesk prompt template: {default_template.name}")
return default_template.content
except Exception as e:
_logger.error(f"Error retrieving prompt template: {str(e)}")
# Fallback to hardcoded template if all else fails
_logger.warning("Using fallback hardcoded prompt template")
return """
Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
Ticket Description: {description}
Customer: {customer}
Chatter Messages: {chatter_messages}
Attachments: {attachments_info}
Attachment Contents: {attachment_contents}
Please provide a list of products/services with quantities and descriptions in the following format:
Product/Service Name | Quantity | Description

View file

@ -4,9 +4,14 @@ from odoo.exceptions import UserError
import logging
import json
import re
import shutil
from pathlib import Path
_logger = logging.getLogger(__name__)
# Import the client model to ensure it's loaded
from . import ai_openwebui_client
class HelpdeskTicket(models.Model):
_inherit = 'helpdesk.ticket'
@ -46,157 +51,31 @@ class HelpdeskTicket(models.Model):
def _ai_convert_to_sale_order(self):
"""Create a sale order using AI to suggest products based on ticket description"""
self.ensure_one()
values = self._get_sale_order_values()
values["partner_id"] = self.partner_id.id
_logger.info("Starting AI conversion to sale order for ticket %s", self.id)
# Add debug logging
_logger.debug(f"Sale order values before create: {values}")
_logger.debug(f"date_order type: {type(values.get('date_order'))}")
# Always generate fresh AI suggestions
_logger.info("Generating fresh AI suggestions for ticket %s", self.id)
result = self._generate_ai_product_suggestions()
_logger.info("AI suggestion generation result for ticket %s: %s", self.id, result)
# Get base values for sale order (partner, pricelist, etc.)
partner_id = self.partner_id.id
partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice']
partner_shipping_id = self.partner_id.address_get(['delivery'])['delivery']
# Parse AI suggestions to get order lines and sale order fields
ai_data = {'order_lines': [], 'sale_order_fields': {}}
if self.ai_generated_products:
_logger.info("AI suggestions found for ticket %s, parsing them now: %s", self.id, self.ai_generated_products[:200])
ai_data = self._parse_ai_product_suggestions()
_logger.info("Parsed AI data: %s", ai_data)
# Prepare sale order values
so_values = {
'partner_id': partner_id,
'partner_invoice_id': partner_invoice_id,
'partner_shipping_id': partner_shipping_id,
'ticket_id': self.id,
'origin': self.name,
'note': self.description,
}
# Add AI-extracted fields to sale order values if available
if ai_data.get('sale_order_fields'):
so_fields = ai_data['sale_order_fields']
# Client order reference (PO number)
if so_fields.get('client_order_ref'):
so_values['client_order_ref'] = so_fields['client_order_ref']
_logger.info(f"Setting client_order_ref to: {so_fields['client_order_ref']}")
# Order date
if so_fields.get('date_order'):
try:
# Validate date format
from datetime import datetime
date_order = datetime.strptime(so_fields['date_order'], '%Y-%m-%d')
so_values['date_order'] = date_order
_logger.info(f"Setting date_order to: {so_fields['date_order']}")
except (ValueError, TypeError) as e:
_logger.warning(f"Invalid date_order format: {so_fields['date_order']}, error: {e}")
# Commitment date (delivery date)
if so_fields.get('commitment_date'):
try:
# Validate date format
from datetime import datetime
commitment_date = datetime.strptime(so_fields['commitment_date'], '%Y-%m-%d')
so_values['commitment_date'] = commitment_date
_logger.info(f"Setting commitment_date to: {so_fields['commitment_date']}")
except (ValueError, TypeError) as e:
_logger.warning(f"Invalid commitment_date format: {so_fields['commitment_date']}, error: {e}")
# Note (special instructions)
if so_fields.get('note'):
# Append to existing note if any
existing_note = so_values.get('note', '')
if existing_note:
so_values['note'] = f"{existing_note}\n\n{so_fields['note']}"
else:
so_values['note'] = so_fields['note']
_logger.info(f"Setting note to: {so_values['note'][:100]}...")
# Payment terms
if so_fields.get('payment_term_id'):
# Try to find matching payment term
payment_term_name = so_fields['payment_term_id']
payment_term = self.env['account.payment.term'].search(
['|', ('name', '=', payment_term_name), ('name', 'ilike', payment_term_name)], limit=1)
if payment_term:
so_values['payment_term_id'] = payment_term.id
_logger.info(f"Setting payment_term_id to: {payment_term.name} (ID: {payment_term.id})")
else:
_logger.warning(f"Payment term not found: {payment_term_name}")
# Ensure date_order is set and is a datetime object
if 'date_order' not in values or not values['date_order']:
from datetime import datetime
values['date_order'] = datetime.now()
_logger.debug(f"Setting default date_order: {values['date_order']}")
# Fix empty string dates by converting them to None
# This prevents PostgreSQL errors with empty string timestamps
date_fields = ['date_order', 'commitment_date', 'validity_date']
for field in date_fields:
if field in values and values[field] == '':
values[field] = None
_logger.debug(f"Converting empty {field} to None")
# Create the sale order
sale_order = self.env['sale.order'].create(so_values)
_logger.info(f"Created sale order with ID {sale_order.id}")
# Add order lines to the sale order
order_lines = ai_data.get('order_lines', [])
_logger.info("Adding %d order lines to sale order %s", len(order_lines), sale_order.id)
for line in order_lines:
# Each line is a tuple (0, 0, values_dict)
# Extract the values dict
line_values = line[2]
_logger.info("Creating order line with values: %s", line_values)
# Create a new order line that will trigger price computation
# Include the price_unit from the parsed data if available
initial_values = {
'order_id': sale_order.id,
'product_id': line_values.get('product_id'),
'product_uom_qty': line_values.get('product_uom_qty'),
'name': line_values.get('name'),
}
# Get the price that was calculated in _create_product_order_line
calculated_price = line_values.get('price_unit')
if calculated_price is not None:
_logger.info(f"Using pre-calculated price: {calculated_price} for product ID: {line_values.get('product_id')}")
initial_values['price_unit'] = calculated_price
order_line = self.env['sale.order.line'].new(initial_values)
# Trigger standard Odoo onchange to compute prices
try:
# This is the main onchange that should set the price based on product and pricelist
order_line._onchange_product_id()
# Log the computed price for debugging
_logger.info(f"Standard Odoo price computation: {order_line.price_unit} for product {order_line.product_id.name}")
# If price is still 0 and product has a list price, use that as fallback
if order_line.price_unit == 0 and order_line.product_id.list_price > 0:
order_line.price_unit = order_line.product_id.list_price
_logger.info(f"Price was 0, using product list price: {order_line.price_unit}")
except Exception as e:
_logger.error(f"Error in standard price computation: {str(e)}")
# Continue with creation even if price computation fails
# Create a clean dict with only the necessary values
order_line_values = {
'order_id': sale_order.id,
'product_id': order_line.product_id.id,
'product_uom_qty': order_line.product_uom_qty,
'name': order_line.name,
'price_unit': order_line.price_unit,
}
# Add product_uom if it exists
if order_line.product_uom:
order_line_values['product_uom'] = order_line.product_uom.id
# Create the actual order line with computed prices
self.env['sale.order.line'].create(order_line_values)
# Link the sale order to the ticket
self.write({
'sale_order_id': sale_order.id,
})
# Return the action to view the created sale order
sale_order = self.env['sale.order'].create(values)
return {
'type': 'ir.actions.act_window',
'name': _('Sale Order'),
@ -205,476 +84,278 @@ class HelpdeskTicket(models.Model):
'view_mode': 'form,list',
'context': self.env.context,
}
def _generate_ai_product_suggestions(self):
"""Use AI to generate product suggestions based on ticket description, chatter messages and attachments"""
def _get_sale_order_values(self) -> dict:
"""
Generate sales order values using AI to analyze ticket content, chatter messages, and attachments.
The AI will identify products from the content and match them to Odoo products using the helper methods.
Returns:
dict: Values for creating a sales order including order lines
"""
self.ensure_one()
_logger.info(f"Generating AI sales order values for ticket {self.id}")
_logger.info("Generating AI product suggestions for ticket %s", self.id)
# Get the ticket data
ticket_data = self._prepare_ai_prompt_data()
description = ticket_data.get('ticket_description', '')
chatter_messages = ticket_data.get('ticket_messages', '')
attachments_info = ticket_data.get('attachments_info', '')
attachment_contents = ticket_data.get('attachment_contents', '')
# Get the ticket description
description = self.description or ""
# Log the content being analyzed
_logger.info(f"AI Analysis - Content lengths: Description={len(description)}, Chatter={len(chatter_messages)}, Attachments={len(attachment_contents)}")
# If description is empty, try to use the name
if not description.strip():
description = self.name or ""
# Get the OpenWebUI provider from company settings
company = self.env.company
provider = company.openwebui_provider_id
# Get chatter messages
chatter_messages = ""
if self.message_ids:
for message in self.message_ids:
if message.body and not message.is_internal:
# Extract text from HTML
body_text = re.sub(r'<[^>]+>', ' ', message.body)
chatter_messages += f"Message from {message.author_id.name or 'Unknown'}: {body_text}\n\n"
if not provider:
_logger.error("No OpenWebUI provider configured for company")
return {"order_line": []}
# Get the OpenWebUI client from the provider
ai_client = provider.get_client()
# Register the product finding methods as tools
registry = ai_client.tool_registry
registry.register(
self._ai_find_product_id_by_name,
non_ai_params=["self"],
description="Find a product by its name and return its ID. Input: name (string) - The name of the product to find. Returns the product ID if found, or null if not found."
)
registry.register(
self._ai_find_product_id_by_code,
non_ai_params=["self"],
description="Find a product by its code/reference and return its ID. Input: code (string) - The code/reference of the product to find. Returns the product ID if found, or null if not found."
)
# Get attachments
attachments_info = ""
attachment_contents = ""
if self.message_ids:
for message in self.message_ids:
if message.attachment_ids:
for attachment in message.attachment_ids:
attachments_info += f"Attachment: {attachment.name} ({attachment.mimetype})\n"
# Extract text from PDF attachments
if attachment.mimetype == 'application/pdf' and attachment.datas:
try:
import base64
import io
# Try to use PyPDF2 if available
try:
from PyPDF2 import PdfReader
pdf_data = base64.b64decode(attachment.datas)
pdf_file = io.BytesIO(pdf_data)
pdf_reader = PdfReader(pdf_file)
pdf_text = ""
for page_num in range(len(pdf_reader.pages)): # Process all pages
page = pdf_reader.pages[page_num]
pdf_text += page.extract_text() + "\n"
attachment_contents += f"Content from {attachment.name}:\n{pdf_text}\n\n" # Include full text
except ImportError:
_logger.warning("PyPDF2 not available, skipping PDF text extraction")
except Exception as e:
_logger.error(f"Error extracting text from PDF: {str(e)}")
# If everything is empty, show error
if not description.strip() and not chatter_messages.strip() and not attachment_contents.strip():
_logger.error("No content available for AI analysis")
return False
attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)])
attachments_list = []
# Process PDF files for analysis
for attachment in attachments:
if attachment.mimetype == 'application/pdf':
try:
temp_path = f"/tmp/{attachment.name}"
shutil.copy(attachment._full_path(attachment.store_fname), temp_path)
attachments_list.append(Path(temp_path))
except Exception as e:
_logger.error(f"Error processing attachment {attachment.name}: {e}")
# Create the prompt for the AI
prompt = f"""You are an expert sales assistant for a pneumatic automation company.
Your task is to analyze the customer request and suggest appropriate products or services.
prompt = f"""IMPORTANT: YOU ARE NOT A CONVERSATIONAL ASSISTANT. YOU ARE A DATA EXTRACTION SYSTEM.
Your ONLY function is to analyze the provided content and return a structured JSON object so that later it can be used to create a sales order.
DO NOT introduce yourself, explain what you can or cannot do, or engage in conversation.
ONLY RETURN THE REQUESTED JSON DATA STRUCTURE.
TASK: Extract product information and sales order details from the following content:
Customer Request:
{description}
Chatter Messages:
Chatter Messages (IMPORTANT - CAREFULLY ANALYZE THESE FOR PRODUCT INFORMATION):
{chatter_messages}
Attachments Information:
{attachments_info}
Attachment Contents:
Attachment Contents (CAREFULLY ANALYZE PDF CONTENTS FOR PRODUCT DETAILS):
{attachment_contents}
Based on this information, please perform two tasks:
WORKFLOW - FOLLOW THESE STEPS EXACTLY:
1. Identify all products mentioned in the content (product names, codes, references)
2. For EACH product identified:
a. If you find a product code/reference, call _ai_find_product_id_by_code with that code
b. If you only have a product name, call _ai_find_product_id_by_name with that name
c. Store the returned product ID (or note if not found)
3. Extract order details (client reference, dates, notes)
4. Construct the JSON response using the product IDs you obtained from tool calls
1. Map the following Odoo sale order fields from the information provided:
- client_order_ref: Customer's reference/PO number
- date_order: Order date (in YYYY-MM-DD format)
- commitment_date: Delivery date (in YYYY-MM-DD format)
- note: Any special instructions or notes
- payment_term_id: Payment terms (e.g., "Net 30", "2% 10 Net 30")
RESPONSE FORMAT:
Your response MUST ONLY be a valid JSON object with the following structure:
2. Suggest products or services that would meet the customer's needs.
IMPORTANT: Your response MUST be in valid JSON format as shown below. Do not include any explanatory text outside the JSON structure.
```json
{{
"sale_order_fields": {{
"client_order_ref": "Customer PO number",
"date_order": "YYYY-MM-DD",
"commitment_date": "YYYY-MM-DD",
"note": "Special instructions",
"payment_term_id": "Payment terms"
}},
"products": [
{{ "name": "Product Name", "quantity": 2, "description": "Product description" }},
{{ "name": "Another Product", "quantity": 1, "description": "Another description" }}
]
"client_order_ref": "Customer PO number if mentioned",
"date_order": "YYYY-MM-DD format if a specific order date is mentioned",
"commitment_date": "YYYY-MM-DD format if a delivery date is mentioned",
"note": "Any special instructions or notes for the order",
"order_line": [
[0, 0, {{
"product_id": NUMERIC_ID_FROM_TOOL_CALL, // Must be an actual ID returned from a tool call
"product_uom_qty": QUANTITY,
"price_unit": PRICE
}}],
[0, 0, {{
"display_type": "line_note",
"name": "Unmatched product: Product description",
"product_uom_qty": 0.0
}}]
]
}}
```
Only include fields and products that are clearly identified from the provided information.
If you're not sure about a field or product, leave it blank or don't include it.
If you cannot identify any products, return an empty products array but still include any sale_order_fields you can identify.
Remember: Your entire response must be valid JSON wrapped in code blocks. No other text.
CRITICAL RULES:
1. You MUST use the provided tools for EVERY product mentioned - DO NOT SKIP THIS STEP
2. product_id MUST be a numeric ID returned by a tool call, NEVER make up IDs
3. For products not found in the database, use the display_type: 'line_note' format
4. Include as much detail as possible for unmatched products
5. Return ONLY valid JSON with no text before or after
6. DO NOT explain what you're doing or respond conversationally
7. DO NOT say you can't create a sales order - your job is ONLY to return the JSON data
IMPORTANT: You must invoke the tools directly using function calling, not just output text that looks like a tool call. Use the provided tools via function calling for _ai_find_product_id_by_code and _ai_find_product_id_by_name.
"""
# Call the AI with tools
try:
# Get the OpenWebUI client
client = self.env['openai.openwebui.client']
_logger.info("Sending request to AI for sales order generation")
# First, get the AI's analysis with tool calls to find product IDs
response = ai_client.chat_with_tools(
messages=[
{
"role": "system",
"content": "You are a data extraction system with access to tools for finding product IDs in an Odoo database. YOU MUST USE THE TOOLS PROVIDED TO ACCURATELY MATCH PRODUCTS PROVIDED TO THE DATABASE. Your ONLY job is to extract product information and return a structured JSON object. DO NOT engage in conversation or explain what you can or cannot do. ONLY return the requested JSON data structure."
},
{
"role": "user",
"content": prompt
}
],
tools=["_ai_find_product_id_by_name", "_ai_find_product_id_by_code"],
tool_params={},
max_tool_calls=25,
files=attachments_list
)
_logger.info(f"Received AI response for ticket {self.id}")
# Create the messages for the AI
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": prompt}
]
# Call the OpenWebUI API
_logger.info("Calling OpenWebUI API for ticket %s", self.id)
response = client.chat_completion(messages)
if response:
_logger.info("Received AI response for ticket %s: %s", self.id, response[:100])
# Store the AI-generated products
self.ai_generated_products = response
return True
else:
_logger.error("Empty response from OpenWebUI API for ticket %s", self.id)
return False
except Exception as e:
_logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, str(e))
_logger.error(f"Error in AI request: {e}")
import traceback
_logger.error("Traceback: %s", traceback.format_exc())
return False
# Note: This method is kept for compatibility but is no longer used
# The _ai_convert_to_sale_order method now creates the sale order directly
def _generate_ai_so_values(self):
"""Generate sale order values with AI-suggested products"""
# Start with the base SO values from the parent method
return self._generate_so_values()
def _parse_ai_product_suggestions(self):
"""Parse the AI-generated product suggestions into sale order lines and fields"""
result = {
'order_lines': [],
'sale_order_fields': {}
_logger.error(f"Traceback: {traceback.format_exc()}")
return {
"order_line": [],
"note": f"AI Error: {str(e)}"
}
# Process the AI response
_logger.debug(f"AI response type: {type(response)}")
# The response from chat_with_tools should contain the content directly
# If it's a dictionary, use it directly
if isinstance(response, dict):
_logger.info("AI returned dictionary response, using directly")
return response
# Handle string responses (extract JSON if possible)
if isinstance(response, str):
_logger.info("AI returned text response, attempting to extract JSON")
try:
# Look for JSON pattern in the text
json_pattern = r'```(?:json)?\s*({[\s\S]*?})\s*```'
json_matches = re.findall(json_pattern, response)
if json_matches:
response_data = json.loads(json_matches[0])
_logger.info("Successfully extracted JSON from text response")
return response_data
elif response.strip().startswith('{') and response.strip().endswith('}'):
response_data = json.loads(response.strip())
_logger.info("Successfully parsed direct JSON from text response")
return response_data
else:
_logger.error("Could not extract JSON from text response")
return {
"order_line": [],
"note": f"AI returned invalid format: {response[:200]}..."
}
except Exception as parse_error:
_logger.error(f"Failed to extract JSON from text response: {parse_error}")
return {
"order_line": [],
"note": f"AI parsing error: {str(parse_error)}"
}
# If we get here, the response is in an unexpected format
_logger.error(f"Unexpected response format: {type(response)}")
return {
"order_line": [],
"note": f"AI returned unexpected format: {type(response)}"
}
if not self.ai_generated_products:
_logger.warning("No AI generated products found for ticket %s", self.id)
return result['order_lines']
def _prepare_ai_prompt_data(self):
"""
Extract and prepare all relevant data from the helpdesk ticket for AI analysis.
This includes ticket description, chatter messages, and attachment contents.
# Log the AI response for debugging
_logger.info("Parsing AI product suggestions for ticket %s: %s", self.id, self.ai_generated_products[:300])
Returns:
dict: Dictionary containing ticket data for AI analysis
"""
self.ensure_one()
_logger.info(f"Preparing AI prompt data for ticket {self.id}")
# First, try to extract JSON from the response using multiple patterns
# Pattern 1: Standard code block with json tag
json_pattern1 = r'```(?:json)?\s*({[\s\S]*?})\s*```'
# Pattern 2: Just find any JSON-like structure with sale_order_fields or products
json_pattern2 = r'({[\s\S]*?"(?:sale_order_fields|products)"[\s\S]*?})'
# Pattern 3: Find any JSON-like structure (most permissive)
json_pattern3 = r'({\s*"[^"]+"\s*:.*})' # Any JSON object with at least one key
result = {
'ticket_description': '',
'ticket_messages': '',
'attachments_info': '',
'attachment_contents': ''
}
json_matches = re.findall(json_pattern1, self.ai_generated_products)
# Get ticket description
if self.description:
result['ticket_description'] = self.description
if not json_matches:
_logger.info("No JSON found with pattern 1, trying pattern 2")
json_matches = re.findall(json_pattern2, self.ai_generated_products)
# Get chatter messages
messages = []
if self.message_ids:
for message in self.message_ids:
if message.body and not message.is_internal:
# Skip system messages and focus on actual conversation
if not message.author_id or message.author_id.name != 'OdooBot':
# Format: [Author] on [Date]: [Message]
author = message.author_id.name if message.author_id else 'System'
date = message.date.strftime('%Y-%m-%d %H:%M') if message.date else ''
# Clean HTML from message body
body = re.sub(r'<[^>]+>', ' ', message.body)
messages.append(f"[{author}] on {date}: {body}")
result['ticket_messages'] = '\n\n'.join(messages)
# Get attachments
attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)])
attachment_infos = []
attachment_contents = []
for attachment in attachments:
# Add attachment metadata
attachment_infos.append(f"File: {attachment.name} ({attachment.mimetype}, {attachment.file_size} bytes)")
if not json_matches:
_logger.info("No JSON found with pattern 2, trying pattern 3")
json_matches = re.findall(json_pattern3, self.ai_generated_products)
if json_matches:
# Try to parse the JSON
try:
# Clean up the JSON string before parsing
json_str = json_matches[0]
# Remove any trailing commas before closing brackets (common JSON error)
json_str = re.sub(r',\s*([\]\}])', r'\1', json_str)
json_data = json.loads(json_str)
_logger.info(f"Successfully parsed JSON data: {json_data}")
# Extract sale order fields
if 'sale_order_fields' in json_data:
result['sale_order_fields'] = json_data['sale_order_fields']
_logger.info(f"Extracted sale order fields: {result['sale_order_fields']}")
# Direct fields at root level (fallback)
elif any(key in json_data for key in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']):
so_fields = {}
for field in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']:
if field in json_data:
so_fields[field] = json_data[field]
result['sale_order_fields'] = so_fields
_logger.info(f"Extracted sale order fields from root level: {result['sale_order_fields']}")
# Extract products - check multiple possible keys
product_key = None
for key in ['products', 'product_suggestions', 'order_lines', 'items']:
if key in json_data and isinstance(json_data[key], list):
product_key = key
break
if product_key:
for product in json_data[product_key]:
if not isinstance(product, dict):
continue
product_name = product.get('name')
if not product_name:
continue
quantity = product.get('quantity', 1.0)
try:
quantity = float(quantity)
except (ValueError, TypeError):
quantity = 1.0
description = product.get('description', '')
_logger.info(f"Processing product from JSON: {product_name}, qty={quantity}, desc={description}")
order_line = self._create_product_order_line(product_name, quantity, description)
if order_line:
result['order_lines'].append(order_line)
_logger.info(f"Parsed {len(result['order_lines'])} order lines from JSON")
return result
except json.JSONDecodeError as e:
_logger.error(f"Failed to parse JSON: {e}")
# Try to extract just the sale order fields using regex as a last resort
# Extract content from PDFs
if attachment.mimetype == 'application/pdf':
try:
# Look for client_order_ref pattern
po_pattern = r'(?:client_order_ref|PO number|purchase order)[\s"]*[:=]\s*["]*([^"\n,}]+)'
po_match = re.search(po_pattern, self.ai_generated_products, re.IGNORECASE)
if po_match:
result['sale_order_fields']['client_order_ref'] = po_match.group(1).strip()
# Look for dates
date_pattern = r'(?:date_order|order date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
date_match = re.search(date_pattern, self.ai_generated_products, re.IGNORECASE)
if date_match:
result['sale_order_fields']['date_order'] = date_match.group(1)
# Look for commitment date
commit_pattern = r'(?:commitment_date|delivery date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
commit_match = re.search(commit_pattern, self.ai_generated_products, re.IGNORECASE)
if commit_match:
result['sale_order_fields']['commitment_date'] = commit_match.group(1)
# Look for payment terms
payment_pattern = r'(?:payment_term_id|payment terms)[\s"]*[:=]\s*["]*([^"\n,}]+)'
payment_match = re.search(payment_pattern, self.ai_generated_products, re.IGNORECASE)
if payment_match:
result['sale_order_fields']['payment_term_id'] = payment_match.group(1).strip()
if result['sale_order_fields']:
_logger.info(f"Extracted sale order fields using regex: {result['sale_order_fields']}")
except Exception as regex_error:
_logger.error(f"Error in regex extraction fallback: {regex_error}")
# If JSON parsing failed, fall back to the old parsing methods
_logger.info("Falling back to legacy parsing methods")
# Try to extract reference number using regex before falling back to line-by-line parsing
ref_patterns = [
r'(?:reference|ticket|po|purchase order)[\s\-]*(?:number|#)?[\s\-:]*([\d\-]+)',
r'(?:client_order_ref|order ref)[\s"]*[:=]\s*["]*([^"\n,}]+)'
]
for pattern in ref_patterns:
ref_match = re.search(pattern, self.ai_generated_products, re.IGNORECASE)
if ref_match:
ref_number = ref_match.group(1).strip()
_logger.info(f"Found reference number using regex: {ref_number}")
result['sale_order_fields']['client_order_ref'] = ref_number
break
order_lines = []
# Try to parse the AI response in different formats
# First, look for a table format with | separators
table_pattern = r"([^|\n]+)\s*\|\s*(\d*\.?\d*)\s*\|\s*([^|\n]*)"
table_matches = re.findall(table_pattern, self.ai_generated_products)
if table_matches:
# Process table format
_logger.info(f"Found table format with {len(table_matches)} matches")
for match in table_matches:
product_name = match[0].strip()
if not product_name or product_name.lower() in ['product/service name', 'product', 'service', 'item']:
continue
# Create temporary file
temp_path = f"/tmp/{attachment.name}"
shutil.copy(attachment._full_path(attachment.store_fname), temp_path)
# Parse quantity
quantity = 1.0
if match[1].strip():
try:
quantity = float(match[1].strip())
except ValueError:
quantity = 1.0
# Get description
description = match[2].strip() if match[2].strip() else product_name
# Add the order line
order_line = self._create_product_order_line(product_name, quantity, description)
if order_line:
order_lines.append(order_line)
else:
# Try to parse line by line for products and quantities
# Look for patterns like "2x Product Name" or "Product Name (qty: 3)" or "Product Name - 4 units"
lines = self.ai_generated_products.strip().split('\n')
_logger.info(f"Parsing line by line, found {len(lines)} lines")
# Skip header lines and empty lines
processed_lines = []
for line in lines:
line = line.strip()
# Skip empty lines, headers, and other non-product lines
if (not line or
line.startswith('#') or
line.lower().startswith('product') or
line.lower() == 'format your response as' or
line.lower() == 'for example:'):
continue
# Remove bullet points and other common prefixes
line = re.sub(r'^[-*\u2022]\s*', '', line)
processed_lines.append(line)
for line in processed_lines:
_logger.info(f"Processing line: {line}")
# Try to extract quantity, product name, part number, and description
# Format examples:
# - 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency
# - 1x Preventive Maintenance Service: Annual service package
# - 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI
# Pattern for the format specified in the prompt template
detailed_pattern = r"(\d+)x\s+([^:]+?)(?:\s+([A-Z0-9][A-Z0-9-]+))?\s*:?\s*(.*)"
match = re.search(detailed_pattern, line, re.IGNORECASE)
if match:
quantity = float(match.group(1))
product_name = match.group(2).strip()
part_number = match.group(3) if match.group(3) else ''
specs = match.group(4).strip() if match.group(4) else ''
# For PDF extraction, we'll just note the PDF file is present
# The actual extraction will be handled by the AI service which has built-in PDF processing
attachment_contents.append(f"PDF file: {attachment.name} (will be processed by AI)")
# Combine part number with product name if available
if part_number:
full_product_name = f"{product_name} {part_number}"
else:
full_product_name = product_name
# Use specifications as description if available
description = specs if specs else product_name
_logger.info(f"Matched detailed pattern: qty={quantity}, product={full_product_name}, desc={description}")
order_line = self._create_product_order_line(full_product_name, quantity, description)
if order_line:
order_lines.append(order_line)
continue
# Try other common patterns if the detailed pattern didn't match
qty_patterns = [
r"(\d+(?:\.\d+)?)\s*x\s*([^\d\n]+)", # "2x Product Name" or "2.5x Product Name"
r"([^\d\n]+)\s*\(\s*qty\s*:\s*(\d+(?:\.\d+)?)\s*\)", # "Product Name (qty: 3)"
r"([^\d\n]+)\s*-\s*(\d+(?:\.\d+)?)\s*units?", # "Product Name - 4 units"
r"([^\d\n]+)\s*:\s*(\d+(?:\.\d+)?)", # "Product Name: 2"
r"quantity\s*:\s*(\d+(?:\.\d+)?)\s*,?\s*([^,]+)", # "Quantity: 2, Product Name"
]
product_name = None
quantity = 1.0
description = ""
for pattern in qty_patterns:
match = re.search(pattern, line, re.IGNORECASE)
if match:
if pattern == qty_patterns[0]: # "2x Product Name"
try:
quantity = float(match.group(1))
product_name = match.group(2).strip()
except (ValueError, IndexError):
continue
else: # Other patterns
try:
product_name = match.group(1).strip()
quantity = float(match.group(2))
except (ValueError, IndexError):
continue
# Try to extract description after the product name
desc_match = re.search(r"[^:]+:(.+)$", line)
if desc_match:
description = desc_match.group(1).strip()
_logger.info(f"Matched pattern {pattern}: qty={quantity}, product={product_name}, desc={description}")
break
# If no pattern matched, use the whole line as product name
if not product_name:
# Check if there's a colon that might separate product name from description
if ':' in line:
parts = line.split(':', 1)
product_name = parts[0].strip()
description = parts[1].strip() if len(parts) > 1 else ''
else:
product_name = line
description = ''
_logger.info(f"No pattern match, using line as product: {product_name}, desc={description}")
# Add the order line
order_line = self._create_product_order_line(product_name, quantity, description)
if order_line:
order_lines.append(order_line)
# Note: If PDF text extraction is needed directly in Odoo, consider adding:
# - A dependency on pdf2text or PyPDF2 in the module manifest
# - Implementing the extraction logic here
except Exception as e:
_logger.error(f"Error extracting content from PDF {attachment.name}: {e}")
result['attachments_info'] = '\n'.join(attachment_infos)
result['attachment_contents'] = '\n\n'.join(attachment_contents)
result['order_lines'] = order_lines
_logger.info(f"Parsed {len(order_lines)} order lines from AI suggestions")
return result
def _create_product_order_line(self, product_name, quantity, description=""):
"""Create a sale order line for a product"""
if not product_name:
return False
# Search for matching product - try exact match first
product = self.env['product.product'].search([
('name', '=', product_name),
('sale_ok', '=', True)
], limit=1)
# If no exact match, try partial match
if not product:
product = self.env['product.product'].search([
('name', 'ilike', product_name),
('sale_ok', '=', True)
], limit=1)
# If still no product found, try matching by default_code (SKU/part number)
if not product and any(c.isdigit() for c in product_name): # Check if product name contains numbers (likely a part number)
# Extract potential part numbers
part_numbers = re.findall(r'[A-Z0-9][A-Z0-9-]+', product_name)
for part in part_numbers:
product = self.env['product.product'].search([
('default_code', '=', part),
('sale_ok', '=', True)
], limit=1)
if product:
break
# If no product found, log it and return False
if not product:
_logger.info(f"No matching product found for: {product_name}")
return False
def _prepare_order_line_values(self, product, quantity, description=""):
"""Prepare values for creating a sale order line"""
# Create order line with price information
line_values = {
'product_id': product.id,
@ -689,4 +370,35 @@ class HelpdeskTicket(models.Model):
# We intentionally don't set price_unit here to let Odoo's standard mechanisms handle it
return (0, 0, line_values)
return line_values
def _ai_find_product_id_by_name(self, product_name: str) -> int | None:
"""Use AI to find a product by name
Args:
product_name: The name of the product to find
Returns:
The ID of the product if found, or None if not found
"""
return self.env['product.product'].search([
('name', 'ilike', product_name),
('sale_ok', '=', True)
], limit=1).id
def _ai_find_product_id_by_code(self, product_reference: str) -> int | None:
"""Use AI to find a product by code
Args:
product_reference: The code of the product to find
Returns:
The ID of the product if found, or None if not found
"""
return self.env['product.product'].search([
('default_code', 'ilike', product_reference),
('sale_ok', '=', True)
], limit=1).id

File diff suppressed because it is too large Load diff

View file

@ -7,23 +7,12 @@ _logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# We no longer need to define the fields here as they are now defined in the openai_connector module
# This ensures that the settings are always in sync between the two modules
# Override the model field to use our selection method
openai_model = fields.Selection(
selection='_get_openwebui_models',
string='AI Model',
help='Model to use for AI API calls',
default='anthropic.claude-3-7-sonnet-latest',
config_parameter='openai.model',
)
# Inherit the fields from openwebui_base module
# These fields are already defined in the openwebui_base module
# Add a field to select the prompt template directly
# In Odoo 18, we need to ensure this field is properly defined
helpdesk_ai_prompt_template_id = fields.Many2one(
'openwebui.prompt.template',
domain="[('template_type', '=', 'helpdesk')]",
string='Default AI Prompt Template',
help='Default template for the prompt sent to the AI.',
ondelete='set null', # This helps avoid constraint errors
@ -55,37 +44,6 @@ class ResConfigSettings(models.TransientModel):
IrConfigParam = self.env['ir.config_parameter'].sudo()
if self.helpdesk_ai_prompt_template_id:
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.helpdesk_ai_prompt_template_id.id))
else:
# If no template is selected, ensure there's a default one
self._set_default_values()
@api.model
def _get_openwebui_models(self):
"""Get available models from OpenWebUI API"""
try:
client = self.env['openwebui.client']
models = client.get_available_models()
return models
except Exception as e:
_logger.error(f"Error fetching OpenWebUI models: {e}")
# Return default model on error
default_model = 'anthropic.claude-3-7-sonnet-latest'
return [(default_model, default_model)]
@api.model
def _set_default_values(self):
"""Set default values for configuration parameters"""
# Ensure there's a default template
template_model = self.env['openwebui.prompt.template']
template_model._ensure_default_template()
default_template = template_model.get_default_template()
# Set the default template ID in config parameters if not set
IrConfigParam = self.env['ir.config_parameter'].sudo()
default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
if not default_template_id and default_template:
# Store as string to ensure compatibility with the OpenAI connector module
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
@api.model
def get_prompt_template(self):
@ -96,10 +54,10 @@ class ResConfigSettings(models.TransientModel):
if template_id:
try:
# Make sure the template model exists first
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
self.env['ai.openwebui.prompt.template']._ensure_default_template('helpdesk')
# The template_id is already an integer in the database now
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
template = self.env['openwebui.prompt.template'].browse(template_id_int)
template = self.env['ai.openwebui.prompt.template'].browse(template_id_int)
if template.exists():
return template.content
except (ValueError, TypeError) as e:
@ -107,5 +65,5 @@ class ResConfigSettings(models.TransientModel):
pass
# Fallback to default template
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
default_template = self.env['ai.openwebui.prompt.template'].get_default_template('helpdesk')
return default_template.content if default_template else ""

View file

@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
import logging
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
@ -27,3 +30,56 @@ class SaleOrder(models.Model):
copy=False,
help='Helpdesk ticket from which this sale order was created'
)
def action_confirm(self):
"""Override to delete missing products message when sale order is confirmed"""
# Call the original method first
result = super(SaleOrder, self).action_confirm()
# Delete missing products message if exists
if self.has_missing_products:
self._delete_missing_products_message()
# Reset missing products flags
self.write({
'missing_product_count': 0,
'has_missing_products': False
})
return result
def _delete_missing_products_message(self):
"""Delete the missing products message from the chatter"""
# Find the message with the unique identifier
domain = [
('res_id', '=', self.id),
('model', '=', 'sale.order'),
('body', 'ilike', '%<!-- MISSING_PRODUCTS_MESSAGE -->%'),
]
messages = self.env['mail.message'].sudo().search(domain)
if messages:
_logger.info(f"Found {len(messages)} missing products message(s) for sale order {self.id} with IDs: {messages.ids}")
try:
# Make sure we're actually deleting the message
message_ids = messages.ids # Store IDs before deletion
messages.sudo().unlink()
_logger.info(f"Successfully deleted missing products message(s) with IDs: {message_ids} for sale order {self.id}")
except Exception as e:
_logger.error(f"Error deleting missing products message: {e}")
else:
_logger.warning(f"No missing products message found for sale order {self.id} - check HTML comment marker")
# Debug: Try a broader search to see if there are any messages at all
all_messages = self.env['mail.message'].sudo().search([
('res_id', '=', self.id),
('model', '=', 'sale.order'),
], limit=5)
if all_messages:
_logger.info(f"Found {len(all_messages)} recent messages for this sale order. First message body sample: {all_messages[0].body[:100] if all_messages[0].body else 'Empty'}...")
else:
_logger.info(f"No messages found at all for sale order {self.id}")

View file

@ -1,3 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_openwebui_prompt_template_helpdesk_user,openwebui.prompt.template.helpdesk.user,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
access_openwebui_prompt_template_helpdesk_manager,openwebui.prompt.template.helpdesk.manager,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1
access_ai_openwebui_prompt_template_helpdesk_user,ai.openwebui.prompt.template.helpdesk.user,openwebui_base.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
access_ai_openwebui_prompt_template_helpdesk_manager,ai.openwebui.prompt.template.helpdesk.manager,openwebui_base.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_openwebui_prompt_template_helpdesk_user access_ai_openwebui_prompt_template_helpdesk_user openwebui.prompt.template.helpdesk.user ai.openwebui.prompt.template.helpdesk.user openwebui_connector.model_openwebui_prompt_template openwebui_base.model_openwebui_prompt_template helpdesk.group_helpdesk_user 1 0 0 0
3 access_openwebui_prompt_template_helpdesk_manager access_ai_openwebui_prompt_template_helpdesk_manager openwebui.prompt.template.helpdesk.manager ai.openwebui.prompt.template.helpdesk.manager openwebui_connector.model_openwebui_prompt_template openwebui_base.model_openwebui_prompt_template helpdesk.group_helpdesk_manager 1 1 1 1

View file

@ -6,40 +6,19 @@
<field name="model">openwebui.prompt.template</field>
<field name="arch" type="xml">
<form string="AI Prompt Template">
<field name="template_type" invisible="1"/>
<field name="template_type" invisible="0"/>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="set_as_default" type="object"
class="oe_stat_button" icon="fa-star"
invisible="is_default">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Set as Default</span>
</div>
</button>
<button name="set_as_default" type="object"
class="oe_stat_button" icon="fa-star" disabled="1"
invisible="not is_default">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Default Template</span>
</div>
</button>
</div>
<div class="oe_title">
<h1><field name="name" placeholder="Template Name"/></h1>
</div>
<group>
<group>
<field name="sequence" groups="base.group_no_one"/>
<field name="active" widget="boolean_toggle"/>
<field name="is_default" invisible="1"/>
<field name="is_default"/>
</group>
</group>
<notebook>
<page string="Template Content" name="template_content">
<div class="text-muted mb-3">
Use placeholders like {description}, {customer}, etc. to include ticket information in the prompt.
</div>
<field name="content" widget="text" class="oe_edit_only" style="min-height: 300px;"/>
<page string="Template Content">
<field name="content" nolabel="1" placeholder="Enter the prompt template content here..."/>
</page>
</notebook>
</sheet>
@ -53,11 +32,9 @@
<field name="model">openwebui.prompt.template</field>
<field name="arch" type="xml">
<list>
<field name="template_type" invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="template_type"/>
<field name="name"/>
<field name="is_default" string="Default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
@ -68,10 +45,12 @@
<field name="model">openwebui.prompt.template</field>
<field name="arch" type="xml">
<search string="Search AI Prompt Templates">
<field name="template_type" invisible="1"/>
<field name="template_type"/>
<field name="name"/>
<filter string="Default" name="default" domain="[('is_default', '=', True)]"/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<filter string="Helpdesk Templates" name="helpdesk" domain="[('template_type', '=', 'helpdesk')]"/>
<filter string="General Templates" name="general" domain="[('template_type', '=', 'general')]"/>
<filter string="Custom Templates" name="custom" domain="[('template_type', '=', 'custom')]"/>
</search>
</field>
</record>
@ -81,14 +60,15 @@
<field name="name">AI Prompt Templates</field>
<field name="res_model">openwebui.prompt.template</field>
<field name="domain">[('template_type', '=', 'helpdesk')]</field>
<field name="context">{'default_template_type': 'helpdesk'}</field>
<field name="context">{"default_template_type": "helpdesk"}</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_helpdesk_ai_prompt_template_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new AI prompt template
Create your first AI prompt template!
</p>
<p>
Define templates for AI prompts used in helpdesk tickets.
AI prompt templates are used to generate product suggestions from helpdesk tickets.
</p>
</field>
</record>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="helpdesk_ticket_form_view_inherit_ai_button" model="ir.ui.view">
<field name="name">helpdesk.ticket.form.view.inherit.ai.button</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk_sale_order.helpdesk_ticket_form_view_inherit_saleorder"/>
<field name="arch" type="xml">
<!-- Update the Convert to Quotation button to be visible when either team_use_sale_orders OR team_use_ai_sale_orders is True -->
<xpath expr="//button[@name='action_convert_to_sale_order']" position="attributes">
<attribute name="invisible">not team_use_sale_orders and not team_use_ai_sale_orders</attribute>
</xpath>
</field>
</record>
</odoo>

View file

@ -1,16 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- We need to make the field available in the form without adding UI elements -->
<!-- Add the hidden field to the base settings form -->
<record id="res_config_settings_view_form_inherit_helpdesk_sale_order_ai" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.helpdesk.sale.order.ai</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<!-- In Odoo 18, we add fields to the form directly -->
<form position="inside">
<!-- Hidden field that will be used by the settings logic -->
<!-- Add the hidden field at the root level of the form -->
<field name="company_id" position="after">
<field name="helpdesk_ai_prompt_template_id" invisible="1"/>
</form>
</field>
</field>
</record>
</odoo>

View file

@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import openwebui_config_wizard

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,30 @@
# Bemade Inc.
#
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : mdurepos@durpro.com)
#
# This program is under the terms of the GNU Lesser General Public License (LGPL-3)
# For details, visit https://www.gnu.org/licenses/lgpl-3.0.en.html
{
"name": "OpenWebUI Base",
"version": "18.0.0.1.0",
"license": "LGPL-3",
"category": "Tools",
"summary": "Base module for OpenWebUI integration",
"author": "Bemade Inc.",
"website": "https://www.bemade.org",
"depends": ["base", "base_setup"],
"external_dependencies": {
"python": ["openwebui-client>=0.3.0"],
},
"data": [
"security/ir.model.access.csv",
"views/openwebui_provider_views.xml",
"views/openwebui_model_views.xml",
"views/res_config_settings.xml",
],
"installable": True,
"application": False,
"auto_install": False,
}

View file

@ -0,0 +1,5 @@
from . import res_company
from . import res_config_settings
from . import openwebui_model
from . import openwebui_provider
from . import openwebui_prompt_template

View file

@ -0,0 +1,29 @@
from odoo import models, fields, api
class OpenWebUIModel(models.Model):
_name = "openwebui.model"
_description = "OpenWebUI Model"
technical_name = fields.Char(
required=True,
help="Technical name of the model on the OpenWebUI server.",
readonly=True,
)
name = fields.Char(
required=True,
help="User-friendly name of the model.",
readonly=True,
)
provider_id = fields.Many2one(
"openwebui.provider",
required=True,
)
_sql_constraints = [
(
"technical_name_provider_unique",
"unique(technical_name, provider_id)",
"Technical name must be unique per provider",
)
]

View file

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class OpenWebUIPromptTemplate(models.Model):
_name = "openwebui.prompt.template"
_description = "OpenWebUI Prompt Template"
name = fields.Char(string="Name", required=True)
template_type = fields.Selection([
('helpdesk', 'Helpdesk'),
('general', 'General'),
('custom', 'Custom'),
], string="Template Type", default='general', required=True)
content = fields.Text(string="Template Content", required=True)
is_default = fields.Boolean(string="Is Default", default=False)
@api.model
def _ensure_default_template(self, template_type='helpdesk'):
"""Ensure that a default template exists for the given type"""
default_template = self.search([
('template_type', '=', template_type),
('is_default', '=', True)
], limit=1)
if not default_template:
# Create a default template based on the type
if template_type == 'helpdesk':
self.create({
'name': 'Default Helpdesk Template',
'template_type': 'helpdesk',
'content': """You are a helpful assistant analyzing helpdesk tickets to create sales orders.
IMPORTANT: In this system, product names are often formatted as the product reference number in square brackets followed by the product name (for example: [REF123] Widget). You can use the reference code inside the brackets to identify products in the database. If you are unsure, select the product with the highest matching reference code.
Please analyze the following ticket information and extract:
1. Client reference number (e.g., PO-12345, REF-678)
2. Products or services needed with quantities and descriptions
3. Any special requirements or notes
4. Delivery date expectations
5. Payment terms if mentioned
Ticket Information:
{ticket_description}
{ticket_messages}
Please format your response as a structured JSON with the following format:
{
"sale_order_fields": {
"client_order_ref": "Reference number if found",
"commitment_date": "YYYY-MM-DD if found",
"payment_term_id": "Payment terms if found"
},
"order_lines": [
{
"product_name": "Product name or description",
"quantity": 1.0,
"notes": "Any special instructions for this line"
}
]
}""",
'is_default': True
})
elif template_type == 'general':
self.create({
'name': 'Default General Template',
'template_type': 'general',
'content': """You are a helpful assistant. Please provide a detailed and accurate response to the following query:
{query}""",
'is_default': True
})
@api.model
def get_default_template(self, template_type='helpdesk'):
"""Get the default template for the given type"""
self._ensure_default_template(template_type)
return self.search([
('template_type', '=', template_type),
('is_default', '=', True)
], limit=1)

View file

@ -0,0 +1,66 @@
from odoo import models, fields, api
from .openwebui_model import OpenWebUIModel
import openwebui_client
from typing import Optional
class OpenWebUIProvider(models.Model):
_name = "openwebui.provider"
_description = "OpenWebUI Provider"
name = fields.Char(string="Name", required=True)
base_url = fields.Char(
string="Base URL",
required=True,
help="Base URL of the OpenWebUI server.",
)
api_key = fields.Char(
string="API Key",
required=True,
help="API key for authentication.",
groups="base.group_system",
)
model_ids = fields.One2many(
comodel_name="openwebui.model",
inverse_name="provider_id",
)
default_model_id = fields.Many2one(
comodel_name="openwebui.model",
)
def get_client(
self, model: Optional[OpenWebUIModel] = None
) -> openwebui_client.OpenWebUIClient:
if not self.base_url or not self.api_key:
raise ValueError("Base URL and API key are required")
model = model or self.default_model_id
return openwebui_client.OpenWebUIClient(
base_url=self.base_url,
api_key=self.api_key,
default_model=model.technical_name if model else None,
)
def sync_models(self):
client = self.get_client()
local_models = self.model_ids
remote_models = {model.id: model.name for model in client.models.list()}
for model in local_models:
if model.technical_name not in remote_models.keys():
model.unlink()
elif model.name != remote_models[model.technical_name]:
model.name = remote_models[model.technical_name]
for model_id, model_name in remote_models.items():
if model_id not in local_models.mapped("technical_name"):
self.env["openwebui.model"].create(
{
"name": model_name,
"technical_name": model_id,
"provider_id": self.id,
}
)
def create(self, vals_list):
providers = super().create(vals_list)
for provider in providers:
provider.sync_models()
return providers

View file

@ -0,0 +1,15 @@
from odoo import models, fields
class Company(models.Model):
_inherit = "res.company"
openwebui_provider_id = fields.Many2one(
"openwebui.provider",
string="OpenWebUI Provider",
)
openwebui_default_model_id = fields.Many2one(
"openwebui.model",
string="OpenWebUI Default Model",
domain=[("provider_id", "=", "openwebui_provider_id")],
)

View file

@ -0,0 +1,44 @@
from odoo import models, fields, api
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
openwebui_provider_id = fields.Many2one(
comodel_name="openwebui.provider",
related="company_id.openwebui_provider_id",
readonly=False,
company_dependent=True,
)
openwebui_default_model_id = fields.Many2one(
comodel_name="openwebui.model",
related="company_id.openwebui_default_model_id",
readonly=False,
company_dependent=True,
)
use_ai_sale_orders = fields.Boolean(
string='Use AI for Sale Orders',
help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions.',
)
@api.model
def get_values(self):
"""Get values for the settings form"""
res = super(ResConfigSettings, self).get_values()
# Get the AI sale orders setting from the system parameter
IrConfigParam = self.env['ir.config_parameter'].sudo()
param_value = IrConfigParam.get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'True')
res['use_ai_sale_orders'] = param_value.lower() == 'true' if isinstance(param_value, str) else bool(param_value)
return res
def set_values(self):
"""Set values from the settings form"""
super(ResConfigSettings, self).set_values()
# Save the AI sale orders setting to the system parameter
IrConfigParam = self.env['ir.config_parameter'].sudo()
IrConfigParam.set_param('helpdesk_sale_order_ai.use_ai_sale_orders', str(self.use_ai_sale_orders))

View file

@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_openwebui_provider_admin,openwebui.provider admin,model_openwebui_provider,base.group_system,1,1,1,1
access_openwebui_provider_user,openwebui.provider user,model_openwebui_provider,base.group_user,1,0,0,0
access_openwebui_model_admin,openwebui.model admin,model_openwebui_model,base.group_system,1,1,1,1
access_openwebui_model_user,openwebui.model user,model_openwebui_model,base.group_user,1,0,0,0
access_openwebui_prompt_template_admin,openwebui.prompt.template admin,model_openwebui_prompt_template,base.group_system,1,1,1,1
access_openwebui_prompt_template_user,openwebui.prompt.template user,model_openwebui_prompt_template,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_openwebui_provider_admin openwebui.provider admin model_openwebui_provider base.group_system 1 1 1 1
3 access_openwebui_provider_user openwebui.provider user model_openwebui_provider base.group_user 1 0 0 0
4 access_openwebui_model_admin openwebui.model admin model_openwebui_model base.group_system 1 1 1 1
5 access_openwebui_model_user openwebui.model user model_openwebui_model base.group_user 1 0 0 0
6 access_openwebui_prompt_template_admin openwebui.prompt.template admin model_openwebui_prompt_template base.group_system 1 1 1 1
7 access_openwebui_prompt_template_user openwebui.prompt.template user model_openwebui_prompt_template base.group_user 1 0 0 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,7 @@
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<!-- Background with rounded corners -->
<rect width="128" height="128" rx="24" ry="24" fill="#1a1a1a"/>
<!-- OI Text -->
<text x="64" y="80" font-family="Arial, sans-serif" font-size="48" font-weight="bold" text-anchor="middle" fill="#f5f5dc">OI</text>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View file

@ -0,0 +1 @@
from . import test_openwebui

View file

@ -0,0 +1,52 @@
from odoo.tests import TransactionCase, Form
import os
class TestOpenWebUI(TransactionCase):
def setUp(self):
super().setUp()
self.provider = self.env["openwebui.provider"].create(
{
"name": "Test Provider",
"base_url": os.getenv("OPENWEBUI_BASE_URL"),
"api_key": os.getenv("OPENWEBUI_API_KEY"),
}
)
def test_sync_models(self):
self.assertTrue(self.provider.model_ids)
self.assertIn(
"MS.qwen3:32b-q8_0", self.provider.model_ids.mapped("technical_name")
)
def test_get_client(self):
client = self.provider.get_client()
self.assertTrue(client)
def test_settings(self):
model = self.provider.model_ids.filtered(
lambda model: model.technical_name == "MS.qwen3:32b-q8_0"
)
wizard = self.env["res.config.settings"].create({})
with Form(wizard) as form:
form.openwebui_provider_id = self.provider
form.openwebui_default_model_id = model
self.assertEqual(self.env.company.openwebui_provider_id, self.provider)
self.assertEqual(self.env.company.openwebui_default_model_id, model)
def test_openwebui_chat(self):
model = self.provider.model_ids.filtered(
lambda model: model.technical_name == "MS.qwen3:32b-q8_0"
)
self.env.company.openwebui_default_model_id = model
self.env.company.openwebui_provider_id = self.provider
response = self.provider.get_client().chat.completions.create(
model=model.technical_name,
messages=[
{
"role": "user",
"content": "Hello, how are you?",
}
],
)
self.assertTrue(response and response.choices)

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View for OpenWebUI Model -->
<record id="view_openwebui_model_form" model="ir.ui.view">
<field name="name">openwebui.model.form</field>
<field name="model">openwebui.model</field>
<field name="arch" type="xml">
<form string="AI Model">
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="Model Name"/>
</h1>
</div>
<group>
<group>
<field name="technical_name" readonly="1"/>
<field name="provider_id" readonly="1"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Tree View for OpenWebUI Model -->
<record id="view_openwebui_model_tree" model="ir.ui.view">
<field name="name">openwebui.model.tree</field>
<field name="model">openwebui.model</field>
<field name="arch" type="xml">
<list string="AI Models">
<field name="name"/>
<field name="technical_name"/>
<field name="provider_id"/>
</list>
</field>
</record>
<!-- Search View for OpenWebUI Model -->
<record id="view_openwebui_model_search" model="ir.ui.view">
<field name="name">openwebui.model.search</field>
<field name="model">openwebui.model</field>
<field name="arch" type="xml">
<search string="Search AI Models">
<field name="name"/>
<field name="technical_name"/>
<field name="provider_id"/>
<group expand="0" string="Group By">
<filter string="Provider" name="group_by_provider" context="{'group_by': 'provider_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action for OpenWebUI Model -->
<record id="action_openwebui_model" model="ir.actions.act_window">
<field name="name">AI Models</field>
<field name="res_model">openwebui.model</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No AI models found
</p>
<p>
AI models are synchronized from your providers.
Add a provider and sync models to see them here.
</p>
</field>
</record>
<!-- Menu Item for OpenWebUI Model -->
<menuitem id="menu_openwebui_model"
name="AI Models"
parent="menu_openwebui_configuration"
action="action_openwebui_model"
sequence="20"
groups="base.group_system"/>
</odoo>

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View for OpenWebUI Provider -->
<record id="view_openwebui_provider_form" model="ir.ui.view">
<field name="name">openwebui.provider.form</field>
<field name="model">openwebui.provider</field>
<field name="arch" type="xml">
<form string="OpenWebUI Provider">
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="Provider Name"/>
</h1>
</div>
<group>
<group>
<field name="base_url" placeholder="https://api.example.com"/>
<field name="api_key" password="True" groups="base.group_system"/>
</group>
<group>
<field name="default_model_id" domain="[('provider_id', '=', id)]"/>
</group>
</group>
<notebook>
<page string="Models" name="models">
<field name="model_ids" readonly="1">
<list>
<field name="name"/>
<field name="technical_name"/>
</list>
</field>
<div class="oe_button_box" name="button_box">
<button name="sync_models" string="Sync Models" type="object" class="oe_highlight" groups="base.group_system"/>
</div>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Tree View for OpenWebUI Provider -->
<record id="view_openwebui_provider_tree" model="ir.ui.view">
<field name="name">openwebui.provider.tree</field>
<field name="model">openwebui.provider</field>
<field name="arch" type="xml">
<list string="OpenWebUI Providers">
<field name="name"/>
<field name="base_url"/>
</list>
</field>
</record>
<!-- Search View for OpenWebUI Provider -->
<record id="view_openwebui_provider_search" model="ir.ui.view">
<field name="name">openwebui.provider.search</field>
<field name="model">openwebui.provider</field>
<field name="arch" type="xml">
<search string="Search OpenWebUI Providers">
<field name="name"/>
<field name="base_url"/>
</search>
</field>
</record>
<!-- Action for OpenWebUI Provider -->
<record id="action_openwebui_provider" model="ir.actions.act_window">
<field name="name">AI Providers</field>
<field name="res_model">openwebui.provider</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first AI provider
</p>
<p>
Configure AI providers to connect with OpenWebUI.
</p>
</field>
</record>
<!-- Menu Item for OpenWebUI Provider -->
<menuitem id="menu_openwebui_root"
name="OpenWebUI"
sequence="100"
groups="base.group_user"/>
<menuitem id="menu_openwebui_configuration"
name="Configuration"
parent="menu_openwebui_root"
sequence="100"
groups="base.group_system"/>
<menuitem id="menu_openwebui_provider"
name="AI Providers"
parent="menu_openwebui_configuration"
action="action_openwebui_provider"
sequence="10"
groups="base.group_system"/>
</odoo>

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.openwebui.base</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app string="OpenWebUI" name="openwebui_base" groups="base.group_system">
<block title="AI Configuration" id="openwebui_config_container">
<setting id="ai_provider" help="Select the AI provider to use with OpenWebUI">
<field name="openwebui_provider_id"/>
</setting>
<setting id="default_model" help="Select the default model to use with OpenWebUI">
<field name="openwebui_default_model_id"/>
</setting>
</block>
<block title="Helpdesk Integration" id="openwebui_helpdesk_integration">
<setting id="use_ai_sale_orders" help="If enabled, the system will use AI to automatically generate sale orders from helpdesk ticket descriptions.">
<field name="use_ai_sale_orders"/>
<div class="text-muted">
Enable AI-powered sale order generation from helpdesk tickets
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
<record id="action_openwebui_config_settings" model="ir.actions.act_window">
<field name="name">Settings</field>
<field name="res_model">res.config.settings</field>
<field name="view_id" ref="res_config_settings_view_form"/>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{'module' : 'openwebui_base', 'bin_size': False}</field>
</record>
</odoo>

View file

@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View file

@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
{
'name': 'OpenWebUI Connector',
'version': '18.0.1.0.0',
'category': 'Tools',
'summary': 'Connect to OpenWebUI and other AI services',
'description': """
OpenWebUI Connector
===============
This module provides integration with OpenWebUI and other AI services.
It allows other modules to use AI capabilities through a standardized interface.
""",
'author': 'Bemade',
'website': 'https://www.bemade.org',
'maintainer': 'it@bemade.org',
'depends': ['base'],
'data': [
'security/ir.model.access.csv',
'data/ai_prompt_template_data.xml',
'views/res_config_settings_views.xml',
'views/ai_prompt_template_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
'external_dependencies': {
'python': ['requests'],
},
}

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Default Helpdesk AI Prompt Template -->
<record id="default_helpdesk_prompt_template" model="openwebui.prompt.template">
<field name="name">Default Helpdesk Template</field>
<field name="template_type">helpdesk</field>
<field name="is_default" eval="True"/>
<field name="sequence">10</field>
<field name="active" eval="True"/>
<field name="content">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.</field>
</record>
<!-- Default General AI Prompt Template -->
<record id="default_general_prompt_template" model="openwebui.prompt.template">
<field name="name">Default General Template</field>
<field name="template_type">general</field>
<field name="is_default" eval="True"/>
<field name="sequence">20</field>
<field name="active" eval="True"/>
<field name="content">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.</field>
</record>
</data>
</odoo>

View file

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
# This file is intentionally left empty to make the directory a proper Python package

View file

@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
# This migration script will run after the module update
# It will ensure the system parameters are properly set up
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Ensure system parameters are properly set up after migration
"""
# Set default values for system parameters
cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('openwebui.base_url', 'https://ai.bemade.org/api', now(), now())
ON CONFLICT (key) DO NOTHING
""")
cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('openwebui.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
ON CONFLICT (key) DO NOTHING
""")
# Log the migration
cr.execute("""
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
'Post-migration: Set default system parameters',
'/addons/openwebui_connector/migrations/1.0/post-migration.py', 30, 'migrate')
""")
# Check if helpdesk_sale_order_ai module is installed
cr.execute("""
SELECT id FROM ir_module_module
WHERE name = 'helpdesk_sale_order_ai'
AND state = 'installed'
""")
if cr.fetchone():
_logger.info("helpdesk_sale_order_ai module is installed, ensuring parameters are set")
# Ensure the helpdesk AI parameter exists
cr.execute("""
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
VALUES ('helpdesk_sale_order_ai.use_ai_sale_orders', 'False', now(), now())
ON CONFLICT (key) DO NOTHING
""")
# Log the migration
cr.execute("""
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
'Post-migration: Set default helpdesk AI parameters',
'/addons/openwebui_connector/migrations/1.0/post-migration.py', 50, 'migrate')
""")

View file

@ -1,66 +0,0 @@
# -*- coding: utf-8 -*-
# This migration script will run before the module update
# It will handle database constraints and column issues for Odoo 18 compatibility
def migrate(cr, version):
"""
Handle database constraints and column issues for Odoo 18 compatibility
"""
# 1. Check and drop any foreign key constraints on helpdesk_ai_prompt_template_id
cr.execute("""
SELECT tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = 'res_config_settings'
AND ccu.column_name = 'helpdesk_ai_prompt_template_id'
AND tc.constraint_type = 'FOREIGN KEY'
""")
constraints = cr.fetchall()
for constraint in constraints:
constraint_name = constraint[0]
# Drop the constraint
cr.execute(f"""
ALTER TABLE res_config_settings
DROP CONSTRAINT IF EXISTS {constraint_name}
""")
# Log the migration
cr.execute("""
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
'Migration: Dropped foreign key constraint: %s',
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 30, 'migrate')
""", (constraint_name,))
# 2. Check if the column exists and drop it if needed
cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'res_config_settings'
AND column_name = 'helpdesk_ai_prompt_template_id'
""")
if cr.fetchone():
# We need to drop the column to avoid conflicts with the new field
try:
cr.execute("""
ALTER TABLE res_config_settings
DROP COLUMN IF EXISTS helpdesk_ai_prompt_template_id
""")
# Log the migration
cr.execute("""
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
'Migration: Dropped column helpdesk_ai_prompt_template_id',
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 50, 'migrate')
""")
except Exception as e:
# Log the error
cr.execute("""
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'error',
'Migration error: %s',
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 60, 'migrate')
""", (str(e),))

View file

@ -1,74 +0,0 @@
# -*- coding: utf-8 -*-
# This migration script will run after the module update
# It will migrate data from openai_prompt_template to openwebui_prompt_template
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Migrate data from openai_prompt_template to openwebui_prompt_template
"""
if not version:
return
_logger.info("Starting migration of prompt templates from openai to openwebui")
# Check if the old table exists
cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
if not cr.fetchone()[0]:
_logger.info("No openai_prompt_template table found, skipping migration")
return
# Check if there's data to migrate
cr.execute("SELECT COUNT(*) FROM openai_prompt_template")
count = cr.fetchone()[0]
_logger.info(f"Found {count} records to migrate from openai_prompt_template")
if count > 0:
# Copy data from old table to new table
cr.execute("""
INSERT INTO openwebui_prompt_template (
id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
)
SELECT
id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
FROM
openai_prompt_template
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
content = EXCLUDED.content,
is_default = EXCLUDED.is_default,
module = EXCLUDED.module,
write_uid = EXCLUDED.write_uid,
write_date = EXCLUDED.write_date
""")
# Update sequence if needed
cr.execute("""
SELECT MAX(id) FROM openwebui_prompt_template
""")
max_id = cr.fetchone()[0] or 0
if max_id > 0:
cr.execute(f"""
SELECT setval('openwebui_prompt_template_id_seq', {max_id})
""")
# Log the migration
_logger.info(f"Successfully migrated {count} records from openai_prompt_template to openwebui_prompt_template")
# Update any references in res_config_settings
cr.execute("""
UPDATE ir_config_parameter
SET key = REPLACE(key, 'openai.', 'openwebui.')
WHERE key LIKE 'openai.%'
""")
# Update any references to prompt template IDs in system parameters
cr.execute("""
UPDATE ir_config_parameter
SET key = REPLACE(key, 'openai_prompt_template_id', 'openwebui_prompt_template_id')
WHERE key LIKE '%openai_prompt_template_id%'
""")
_logger.info("Updated system parameters to use openwebui prefix")

View file

@ -1,54 +0,0 @@
# -*- coding: utf-8 -*-
# This migration script will run before the module update
# It will handle database constraints and prepare for the migration
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""
Handle database constraints and prepare for migration
"""
if not version:
return
_logger.info("Starting pre-migration for openwebui_connector")
# 1. Drop foreign key constraints on ai_prompt_template_id to avoid conflicts
cr.execute("""
SELECT tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = 'res_config_settings'
AND ccu.column_name = 'ai_prompt_template_id'
AND tc.constraint_type = 'FOREIGN KEY'
""")
constraints = cr.fetchall()
for constraint in constraints:
constraint_name = constraint[0]
# Drop the constraint
cr.execute(f"""
ALTER TABLE res_config_settings
DROP CONSTRAINT IF EXISTS {constraint_name}
""")
_logger.info(f"Dropped foreign key constraint: {constraint_name}")
# 2. Update system parameters to use new naming
cr.execute("""
UPDATE ir_config_parameter
SET key = REPLACE(key, 'openai.', 'openwebui.')
WHERE key LIKE 'openai.%'
""")
# 3. Check if the old openai_prompt_template table exists
cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
if cr.fetchone()[0]:
# Create a backup of the data if needed
cr.execute("""
CREATE TABLE IF NOT EXISTS openai_prompt_template_backup AS
SELECT * FROM openai_prompt_template
""")
_logger.info("Created backup of openai_prompt_template data")
_logger.info("Pre-migration completed successfully")

View file

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
# This file is intentionally left empty to make the directory a proper Python package

View file

@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
from . import openwebui_client
from . import ai_prompt_template
from . import res_config_settings

View file

@ -1,187 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class AIPromptTemplate(models.Model):
_name = 'openwebui.prompt.template'
_description = 'AI Prompt Template'
_order = 'sequence, id'
name = fields.Char(string='Name', required=True)
content = fields.Text(
string='Template Content',
required=True,
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.'
)
template_type = fields.Selection([
('helpdesk', 'Helpdesk'),
('general', 'General'),
], string='Template Type', default='helpdesk', required=True)
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
is_default = fields.Boolean(string='Default Template', default=False)
@api.model
def _ensure_default_template(self, template_type='helpdesk'):
"""Ensure that at least one default template exists for the given type"""
_logger.info(f"Ensuring default template exists for type: {template_type}")
try:
default_template = self.search([('template_type', '=', template_type), ('is_default', '=', True)], limit=1)
_logger.info(f"Found existing default template: {bool(default_template)}")
if not default_template:
_logger.info("No default template found, creating one")
# Create a default template
default_content = self._get_default_template_content(template_type)
default_name = f"Default {template_type.capitalize()} Template"
try:
_logger.info(f"Creating default template with name: {default_name}")
default_template = self.create({
'name': default_name,
'template_type': template_type,
'content': default_content,
'is_default': True,
})
_logger.info(f"Default template created with ID: {default_template.id}")
# Set the default template ID in system parameters
if template_type == 'helpdesk':
_logger.info("Setting default template ID in system parameters")
param_name = 'helpdesk_sale_order_ai.default_prompt_template_id'
param_value = str(default_template.id)
_logger.info(f"Setting parameter {param_name} = {param_value}")
self.env['ir.config_parameter'].sudo().set_param(
param_name, param_value
)
except Exception as e:
_logger.error(f"Error creating default template: {e}", exc_info=True)
_logger.error(f"Template data: name={default_name}, type={template_type}, content_length={len(default_content) if default_content else 0}")
return default_template
except Exception as e:
_logger.error(f"Unexpected error in _ensure_default_template: {e}", exc_info=True)
return False
@api.model
def _get_default_template_content(self, template_type):
"""Get the default content for a template type"""
if template_type == 'helpdesk':
return """Based on the following helpdesk ticket information, create a complete sales order that accurately reflects the client's requirements.
Ticket Information: {description}
Customer: {customer}
Subject: {subject}
Analyze all information including ticket description, chatter messages, and attachments to create a comprehensive sales order that includes ALL items the client has requested.
Please provide a detailed list of products and services with exact quantities, specifications, and any relevant details mentioned by the client. Include part numbers when available.
Format your response as follows:
- [Quantity]x [Product Name] [Part Number if available]: [Specifications/Details]
For example:
- 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency as specified in technical document
- 1x Preventive Maintenance Service: Annual service package including parts and labor
- 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI max pressure
If the client has provided pricing expectations or budget constraints, include this information. If specific delivery timeframes are mentioned, note these as well.
"""
elif template_type == 'general':
return """You are a helpful AI assistant. Please analyze the following content and provide a concise and helpful response:
{content}
"""
else:
return """Please provide a prompt template for this type of content."""
def set_as_default(self):
"""Set this template as the default for its type"""
# First, unset any existing default templates of the same type
other_defaults = self.search([
('id', '!=', self.id),
('template_type', '=', self.template_type),
('is_default', '=', True)
])
other_defaults.write({'is_default': False})
# Set this template as default
self.is_default = True
# Update the system parameter if this is a helpdesk template
if self.template_type == 'helpdesk':
self.env['ir.config_parameter'].sudo().set_param(
'helpdesk_sale_order_ai.default_prompt_template_id',
str(self.id)
)
return True
@api.model_create_multi
def create(self, vals_list):
"""Override create to handle default templates"""
records = super(AIPromptTemplate, self).create(vals_list)
# Group templates by type
templates_by_type = {}
for record in records:
if record.is_default:
if record.template_type not in templates_by_type:
templates_by_type[record.template_type] = []
templates_by_type[record.template_type].append(record.id)
# For each type with new default templates, unset default flag on others of the same type
for template_type, template_ids in templates_by_type.items():
self.search([
('id', 'not in', template_ids),
('is_default', '=', True),
('template_type', '=', template_type)
]).write({'is_default': False})
return records
@api.model
def get_default_template(self, template_type='helpdesk'):
"""Get the default template for the given type"""
_logger.info(f"Getting default template for type: {template_type}")
try:
default_template = self.search([
('is_default', '=', True),
('template_type', '=', template_type),
('active', '=', True),
], limit=1)
_logger.info(f"Found default template: {bool(default_template)}")
if not default_template:
_logger.info("No active default template found, ensuring one exists")
# Create a default template if none exists
default_template = self._ensure_default_template(template_type)
_logger.info(f"Default template after ensure: {bool(default_template)} with ID: {default_template.id if default_template else 'None'}")
else:
_logger.info(f"Using existing default template with ID: {default_template.id}")
return default_template
except Exception as e:
_logger.error(f"Error in get_default_template: {e}", exc_info=True)
# Try to create a new template as a fallback
try:
_logger.info("Attempting to create a new default template as fallback")
default_content = self._get_default_template_content(template_type)
default_name = f"Fallback {template_type.capitalize()} Template"
fallback_template = self.create({
'name': default_name,
'template_type': template_type,
'content': default_content,
'is_default': True,
})
_logger.info(f"Created fallback template with ID: {fallback_template.id}")
return fallback_template
except Exception as e2:
_logger.error(f"Failed to create fallback template: {e2}", exc_info=True)
return self.browse() # Return empty recordset

View file

@ -1,359 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
import json
import requests
from typing import Dict, List, Any, Optional, Tuple
_logger = logging.getLogger(__name__)
class OpenWebUIClient(models.AbstractModel):
"""
OpenWebUI client for interacting with the OpenWebUI API.
This is a simplified version of the external OpenWebUI client integrated into Odoo.
"""
_name = 'openwebui.client'
_description = 'OpenWebUI Client for AI Services'
def _get_config(self, raise_if_missing=True):
"""
Get the OpenWebUI configuration from the system parameters.
Uses the dedicated openwebui parameters for API key, base URL and model.
Args:
raise_if_missing: If True, raise an error when API key is missing.
If False, return config with empty API key.
"""
_logger.debug(f"Getting OpenWebUI config (raise_if_missing={raise_if_missing})")
try:
# Get the config from the ir.config_parameter
IrConfigParam = self.env['ir.config_parameter'].sudo()
# Use dedicated OpenWebUI parameters
api_key = IrConfigParam.get_param('openwebui.api_key', False)
# If OpenWebUI API key is not set, try fallback to OpenAI key
if not api_key:
api_key = IrConfigParam.get_param('openai.api_key', False)
_logger.debug("OpenWebUI API key not found, falling back to OpenAI API key")
_logger.debug(f"Retrieved API key: {'Present' if api_key else 'Missing'}")
# Get base URL from dedicated parameter
base_url = IrConfigParam.get_param('openwebui.base_url', 'https://ai.bemade.org/api')
_logger.debug(f"Using base_url: {base_url}")
# Get model from dedicated parameter
model = IrConfigParam.get_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
_logger.debug(f"Using model: {model}")
if not api_key and raise_if_missing:
_logger.error("No API key found in configuration and raise_if_missing=True")
raise ValueError("No API key found in configuration")
config = {
'api_key': api_key or '',
'base_url': base_url,
'model': model
}
_logger.debug("Successfully retrieved OpenWebUI config")
return config
except Exception as e:
_logger.error(f"Error getting OpenWebUI config: {e}", exc_info=True)
raise
def get_available_models(self):
"""
Fetch available models from the OpenWebUI API.
Returns:
A list of tuples (model_id, model_name) suitable for selection fields
"""
_logger.info("Starting get_available_models")
# Define default_model at the beginning to ensure it's always available
default_model = 'anthropic.claude-3-7-sonnet-latest'
# Create a list with just the default model to ensure it's always available
models = [(default_model, default_model)]
try:
# Get config without raising exception if API key is missing
config = self._get_config(raise_if_missing=False)
# Update default_model with configured value and ensure it's in the list
if 'model' in config and config['model']:
default_model = config['model']
# Clear the list and add the current default model
models = [(default_model, default_model)]
_logger.info(f"Default model: {default_model}")
# If no API key is set, just return the current model
if not config.get('api_key'):
_logger.warning("No API key configured, only showing current model")
return models
# Add some common models that we know work with OpenWebUI
# This ensures we have a good selection even if the API call fails
common_models = [
('anthropic.claude-3-7-sonnet-latest', 'Claude 3.7 Sonnet'),
('anthropic.claude-3-5-sonnet-20240620', 'Claude 3.5 Sonnet'),
('anthropic.claude-3-opus-20240229', 'Claude 3 Opus'),
('gpt-4o', 'GPT-4o'),
('gpt-4-turbo', 'GPT-4 Turbo'),
('gpt-3.5-turbo', 'GPT-3.5 Turbo')
]
# Add common models to our list, but keep the default model first
for model_id, model_name in common_models:
if model_id != default_model: # Avoid duplicates
models.append((model_id, model_name))
# Remove trailing slash if present in base_url
base_url = config.get('base_url', 'https://ai.bemade.org/api')
if isinstance(base_url, str) and base_url.endswith('/'):
base_url = base_url[:-1]
_logger.info(f"Using base URL: {base_url}")
# Check if base_url already contains '/api' to avoid duplicates
if '/api' in base_url:
endpoints = [
'/v1/models',
'/models',
'/chat/models',
'/v1/chat/models'
]
else:
endpoints = [
'/v1/models',
'/models',
'/chat/models',
'/api/models',
'/api/v1/models',
'/api/chat/models'
]
_logger.debug(f"Will try these endpoints: {endpoints}")
# Set up authentication headers
headers = {
"Authorization": f"Bearer {config['api_key']}",
"Content-Type": "application/json",
}
_logger.debug("Authentication headers set up")
# Try a simple connection test first with a short timeout
try:
_logger.info(f"Testing connection to base URL: {base_url}")
test_response = requests.get(base_url, timeout=3)
_logger.info(f"Base API connection test: {test_response.status_code}")
if test_response.status_code >= 400:
_logger.warning(f"Base URL returned error status: {test_response.status_code}")
return models # Return our predefined models
except Exception as e:
_logger.warning(f"Could not connect to base API URL: {str(e)}")
# Return our predefined models if we can't connect
_logger.info("Returning predefined models due to connection error")
return models
# Try each endpoint with a short timeout
_logger.info("Starting to try each endpoint for models")
success = False
for endpoint in endpoints:
url = f"{base_url}{endpoint}"
_logger.info(f"Trying endpoint: {url}")
try:
_logger.debug(f"Making request to {url} with timeout=5")
response = requests.get(url, headers=headers, timeout=5)
_logger.info(f"Response status code: {response.status_code}")
if response.status_code != 200:
_logger.info(f"Endpoint {endpoint} returned non-200 status code: {response.status_code}")
continue
# Check if response is empty
if not response.text or response.text.strip() == '':
_logger.warning(f"Empty response from {url}")
continue
# Try to parse the response as JSON
try:
_logger.debug("Parsing response as JSON")
response_data = response.json()
_logger.debug(f"Response data type: {type(response_data)}")
# If we got an empty object or list, skip
if (isinstance(response_data, dict) and not response_data) or \
(isinstance(response_data, list) and not response_data):
_logger.warning(f"Empty JSON object/array from {url}")
continue
except json.JSONDecodeError as je:
_logger.error(f"Failed to parse JSON from {url}: {je}")
continue
# Extract model information from the response
model_ids = []
# Handle different response formats
if isinstance(response_data, dict):
_logger.debug("Response is a dictionary")
# OpenAI format
if "data" in response_data and isinstance(response_data["data"], list):
_logger.debug("Found OpenAI format response with 'data' key")
for model in response_data["data"]:
if isinstance(model, dict) and "id" in model:
model_id = model["id"]
model_name = model.get("name", model_id)
model_ids.append((model_id, model_name))
# Another common format
elif "models" in response_data and isinstance(response_data["models"], list):
_logger.debug("Found response with 'models' key")
for model in response_data["models"]:
if isinstance(model, dict) and "id" in model:
model_id = model["id"]
model_name = model.get("name", model_id)
model_ids.append((model_id, model_name))
else:
_logger.debug(f"Dictionary response keys: {list(response_data.keys())}")
elif isinstance(response_data, list):
_logger.debug("Response is a list")
# Simple list format
for model in response_data:
if isinstance(model, dict) and "id" in model:
model_id = model["id"]
model_name = model.get("name", model_id)
model_ids.append((model_id, model_name))
else:
_logger.warning(f"Unexpected response data type: {type(response_data)}")
if model_ids:
_logger.info(f"Found {len(model_ids)} models from endpoint {url}")
# Add all found models to our list, avoiding duplicates
for model_tuple in model_ids:
if model_tuple not in models:
models.append(model_tuple)
success = True
break
else:
_logger.warning(f"No models found in response from {url}")
except requests.RequestException as e:
_logger.error(f"Request error with endpoint {endpoint}: {e}")
continue
except Exception as e:
_logger.error(f"Unexpected error with endpoint {endpoint}: {e}")
continue
# If we couldn't find any models from API, we still have our predefined models
_logger.info(f"Total models found: {len(models)}")
return models
except Exception as e:
_logger.error(f"Error in get_available_models: {e}")
# Return just the default model on any error
return [(default_model, default_model)]
def chat_completion(self, messages, model=None):
"""
Send a chat completion request to the OpenWebUI API.
Args:
messages: List of message dictionaries with 'role' and 'content' keys
model: Model to use (defaults to the configured model)
Returns:
The response from the OpenWebUI API
"""
_logger.info("Starting chat_completion request")
try:
# For chat completion, we do need a valid API key
_logger.info("Getting config for chat completion")
config = self._get_config(raise_if_missing=True)
# Use the provided model or fall back to the configured model
model = model or config['model']
_logger.info(f"Using model: {model}")
# Remove trailing slash if present in base_url
base_url = config['base_url']
if isinstance(base_url, str) and base_url.endswith('/'):
base_url = base_url[:-1]
_logger.info(f"Using base URL: {base_url}")
# Construct the full URL based on the API provider
if 'openai.com' in base_url:
# Standard OpenAI API format
url = f"{base_url}/chat/completions"
elif 'bemade.org' in base_url:
# For Bemade's API, try without the v1 path as it's returning 405
url = f"{base_url}/chat/completions"
else:
# Generic format, try standard OpenAI path
url = f"{base_url}/chat/completions"
_logger.info(f"Full API URL: {url}")
# Set up authentication headers
headers = {
"Authorization": f"Bearer {config['api_key']}",
"Content-Type": "application/json",
}
_logger.debug("Authentication headers set up")
# Create the payload
payload = {
"model": model,
"messages": messages,
"temperature": 0.7, # Add reasonable temperature for more consistent results
"max_tokens": 4000 # Ensure we get enough tokens for a complete response
}
_logger.info(f"OpenWebUI API request to: {url}")
_logger.debug(f"OpenWebUI API payload: {payload}")
try:
# Make the HTTP request
_logger.info("Sending POST request to OpenWebUI API")
response = requests.post(url, headers=headers, json=payload, timeout=60.0)
_logger.info(f"Response status code: {response.status_code}")
# Log response content for debugging
_logger.debug(f"Response content (first 500 chars): {response.text[:500]}")
# Raise an exception for any HTTP error
response.raise_for_status()
_logger.info("Response status check passed")
# Parse the JSON response
try:
_logger.debug("Parsing response as JSON")
response_data = response.json()
except json.JSONDecodeError as je:
_logger.error(f"Failed to parse JSON response: {je}", exc_info=True)
_logger.error(f"Response content: {response.text[:500]}")
return ""
_logger.debug(f"Response data type: {type(response_data)}")
if isinstance(response_data, dict):
_logger.debug(f"Response keys: {list(response_data.keys())}")
# Extract the content from the response
if response_data and 'choices' in response_data and response_data['choices']:
_logger.info("Successfully extracted content from response")
return response_data['choices'][0]['message']['content']
else:
_logger.error(f"Invalid response format from OpenWebUI API: {response_data}")
return ""
except requests.RequestException as re:
_logger.error(f"Request error calling OpenWebUI API: {re}", exc_info=True)
raise
except Exception as e:
_logger.error(f"Unexpected error in API request: {e}", exc_info=True)
raise
except Exception as e:
_logger.error(f"Error in chat_completion: {e}", exc_info=True)
raise

View file

@ -1,382 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# AI API Configuration (unified for all AI services)
openai_api_key = fields.Char(
string='AI API Key',
help='API key for AI services (OpenAI, OpenWebUI, Claude)',
config_parameter='openwebui.api_key',
)
openai_base_url = fields.Char(
string='AI Base URL',
help='Base URL for AI API',
default='https://api.openai.com/v1', # Default OpenAI endpoint
config_parameter='openwebui.base_url',
)
# Use Selection field with dynamic options from OpenWebUI client
openai_model = fields.Selection(
selection='_get_openwebui_models',
string='AI Model',
help='Model to use for AI API calls',
default='anthropic.claude-3-7-sonnet-latest',
config_parameter='openwebui.model',
)
# Helpdesk AI fields
helpdesk_use_ai_sale_orders = fields.Boolean(
string='Use AI for Sale Orders',
help='If checked, the system will use AI to automatically generate sale orders from helpdesk ticket descriptions.',
config_parameter='helpdesk_sale_order_ai.use_ai_sale_orders',
)
# Add a field to select the prompt template directly
# In Odoo 18, we need to ensure this field is properly defined
ai_prompt_template_id = fields.Integer(
string='Default AI Prompt Template ID',
help='Default template ID for the prompt sent to the AI.',
compute='_compute_ai_prompt_template_id',
inverse='_inverse_ai_prompt_template_id',
store=False, # Don't store this field to avoid constraint issues
)
# Actual relation field for the template, but not stored in database
ai_prompt_template_relation = fields.Many2one(
'openwebui.prompt.template',
string='Default AI Prompt Template',
help='Default template for the prompt sent to the AI.',
compute='_compute_ai_prompt_template_relation',
domain="[('template_type', '=', 'helpdesk')]",
store=False, # Don't store this field to avoid constraint issues
)
# Display field for backward compatibility
helpdesk_ai_template_display = fields.Char(
string='Default AI Prompt Template',
help='Default template for the prompt sent to the AI.',
compute='_compute_helpdesk_ai_template_display',
readonly=True,
store=False, # Important: don't store this field in the database
)
@api.model
def _get_openwebui_models(self):
"""Get available models from OpenWebUI API"""
try:
client = self.env['openwebui.client']
models = client.get_available_models()
return models
except Exception as e:
_logger.error(f"Error fetching OpenWebUI models: {e}")
# Return default model on error
default_model = 'anthropic.claude-3-7-sonnet-latest'
return [(default_model, default_model)]
def _compute_ai_prompt_template_id(self):
"""Compute the template ID from system parameter"""
for record in self:
IrConfigParam = self.env['ir.config_parameter'].sudo()
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
if template_id:
try:
# Convert to integer if it's a string
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
record.ai_prompt_template_id = template_id_int
except (ValueError, TypeError):
record.ai_prompt_template_id = False
else:
record.ai_prompt_template_id = False
def _inverse_ai_prompt_template_id(self):
"""Save the template ID to system parameter"""
for record in self:
IrConfigParam = self.env['ir.config_parameter'].sudo()
if record.ai_prompt_template_id:
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(record.ai_prompt_template_id))
else:
# Try to get a default template
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
if default_template:
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
def _compute_ai_prompt_template_relation(self):
"""Compute the template relation from the ID"""
for record in self:
if record.ai_prompt_template_id:
template = self.env['openwebui.prompt.template'].browse(record.ai_prompt_template_id)
if template.exists():
record.ai_prompt_template_relation = template.id
else:
record.ai_prompt_template_relation = False
else:
record.ai_prompt_template_relation = False
@api.depends('ai_prompt_template_relation')
def _compute_helpdesk_ai_template_display(self):
"""Compute the template name from the selected template"""
for record in self:
if record.ai_prompt_template_relation:
record.helpdesk_ai_template_display = record.ai_prompt_template_relation.name
else:
# Try to get from system parameter for backward compatibility
IrConfigParam = self.env['ir.config_parameter'].sudo()
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
if template_id:
try:
# Convert to integer if it's a string
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
# Try to find the template in the new model
template = self.env['openwebui.prompt.template'].browse(template_id_int)
if template.exists():
record.helpdesk_ai_template_display = template.name
else:
record.helpdesk_ai_template_display = f'Template #{template_id} (not found)'
except (ValueError, TypeError):
record.helpdesk_ai_template_display = f'Template #{template_id} (invalid)'
else:
record.helpdesk_ai_template_display = 'Default Template'
@api.model
def _set_default_values(self):
"""Set default values for configuration parameters"""
IrConfigParam = self.env['ir.config_parameter'].sudo()
base_url = IrConfigParam.get_param('openwebui.base_url')
model = IrConfigParam.get_param('openwebui.model')
# Check for old parameters and migrate them
old_api_key = IrConfigParam.get_param('openai.api_key', False)
old_base_url = IrConfigParam.get_param('openai.base_url', False)
old_model = IrConfigParam.get_param('openai.model', False)
# Migrate old parameters to new ones if they exist
if old_api_key and not IrConfigParam.get_param('openwebui.api_key', False):
IrConfigParam.set_param('openwebui.api_key', old_api_key)
if old_base_url and not base_url:
IrConfigParam.set_param('openwebui.base_url', old_base_url)
if old_model and not model:
IrConfigParam.set_param('openwebui.model', old_model)
# Set defaults if not already set
if not base_url:
IrConfigParam.set_param('openwebui.base_url', 'https://api.openai.com/v1')
if not model:
IrConfigParam.set_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
# Ensure there's a default template
template_model = self.env['openwebui.prompt.template']
template_model._ensure_default_template('helpdesk')
default_template = template_model.get_default_template('helpdesk')
# Set the default template ID in config parameters if not set
default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
if not default_template_id and default_template:
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
@api.model
def create(self, vals_list):
"""Set default values when creating settings"""
self._set_default_values()
return super(ResConfigSettings, self).create(vals_list)
def set_values(self):
"""Set values from the settings form"""
_logger.info("Starting set_values in ResConfigSettings")
try:
# First, explicitly save the API key, base URL, and model to ensure they're saved
# even if there's an error with the template handling
IrConfigParam = self.env['ir.config_parameter'].sudo()
# Save API key, base URL, and model directly with explicit error handling
# API Key - most critical setting
if hasattr(self, 'openai_api_key'):
_logger.info(f"Saving API key (present: {bool(self.openai_api_key)})")
# Always set the parameter, even if None or False, to clear previous value if needed
api_key_value = self.openai_api_key if self.openai_api_key else ''
IrConfigParam.set_param('openai.api_key', api_key_value)
# Verify it was saved
saved_key = IrConfigParam.get_param('openai.api_key', '')
_logger.info(f"API key saved successfully: {bool(saved_key)}")
# Base URL
if hasattr(self, 'openai_base_url'):
_logger.info(f"Saving base URL: {self.openai_base_url}")
base_url_value = self.openai_base_url if self.openai_base_url else 'https://ai.bemade.org/api'
IrConfigParam.set_param('openai.base_url', base_url_value)
# Verify it was saved
saved_url = IrConfigParam.get_param('openai.base_url', '')
_logger.info(f"Base URL saved: {saved_url}")
# Model
if hasattr(self, 'openai_model'):
_logger.info(f"Saving model: {self.openai_model}")
model_value = self.openai_model if self.openai_model else 'anthropic.claude-3-7-sonnet-latest'
IrConfigParam.set_param('openai.model', model_value)
# Verify it was saved
saved_model = IrConfigParam.get_param('openai.model', '')
_logger.info(f"Model saved: {saved_model}")
_logger.info(f"Current settings values: openai_api_key={bool(self.openai_api_key)}, openai_base_url={self.openai_base_url}, openai_model={self.openai_model}")
_logger.info(f"Template ID: {self.ai_prompt_template_id if self.ai_prompt_template_id else 'None'}")
# Force a commit to ensure parameters are saved to database
self.env.cr.commit()
_logger.info("Committed parameter changes to database")
# Now call the super method
res = super(ResConfigSettings, self).set_values()
_logger.info("Super set_values completed successfully")
# Handle the template ID - either save the selected one or ensure a default exists
try:
if self.ai_prompt_template_id:
_logger.info(f"Saving template ID {self.ai_prompt_template_id} to system parameters")
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.ai_prompt_template_id))
else:
# If no template is selected, ensure a default one exists and use that
_logger.info("No template selected, getting default template")
template_model = self.env['openwebui.prompt.template']
try:
default_template = template_model.get_default_template('helpdesk')
if default_template:
_logger.info(f"Using default template ID {default_template.id}")
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
else:
_logger.warning("No default template found")
except Exception as e:
_logger.error(f"Error getting default template: {e}", exc_info=True)
# Continue execution even if there's an error with the template
except Exception as template_error:
_logger.error(f"Error handling template: {template_error}", exc_info=True)
# Continue execution even if there's an error with the template
# Force another commit to ensure all changes are saved
self.env.cr.commit()
_logger.info("Final commit completed")
# Double-check that the API key was saved
final_api_key = IrConfigParam.get_param('openai.api_key', '')
_logger.info(f"Final API key check - key exists: {bool(final_api_key)}")
_logger.info("set_values completed successfully")
return res
except Exception as e:
_logger.error(f"Error in set_values: {e}", exc_info=True)
# Try to save API key directly even if there was an error
try:
if hasattr(self, 'openai_api_key'):
api_key_value = self.openai_api_key if self.openai_api_key else ''
self.env['ir.config_parameter'].sudo().set_param('openai.api_key', api_key_value)
self.env.cr.commit() # Force commit to save the key
_logger.info("Saved API key directly after error")
except Exception as key_error:
_logger.error(f"Failed to save API key after error: {key_error}")
raise
@api.model
def get_values(self):
"""Get values for the settings form"""
res = super(ResConfigSettings, self).get_values()
# Ensure default values are set
self._set_default_values()
# Get all OpenAI settings from system parameters
IrConfigParam = self.env['ir.config_parameter'].sudo()
# Get OpenAI API key, base URL, and model
_logger.info("Retrieving OpenAI settings from system parameters")
api_key = IrConfigParam.get_param('openai.api_key', False)
base_url = IrConfigParam.get_param('openai.base_url', 'https://ai.bemade.org/api')
model = IrConfigParam.get_param('openai.model', 'anthropic.claude-3-7-sonnet-latest')
# Set the values in the result dictionary
res.update({
'openai_api_key': api_key,
'openai_base_url': base_url,
'openai_model': model,
})
_logger.info(f"Retrieved settings: API key present: {bool(api_key)}, base_url: {base_url}, model: {model}")
# Get the template ID from the system parameter
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
if template_id:
try:
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
# Check if the template exists
template = self.env['openwebui.prompt.template'].browse(template_id_int)
if template.exists():
res['ai_prompt_template_id'] = template_id_int
else:
# If template doesn't exist, get a default one
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
if default_template:
res['ai_prompt_template_id'] = default_template.id
# Update the system parameter
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
except (ValueError, TypeError):
_logger.error(f"Invalid template ID in system parameter: {template_id}")
# Get a default template
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
if default_template:
res['ai_prompt_template_id'] = default_template.id
# Update the system parameter
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
else:
# No template ID in system parameters, get a default one
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
if default_template:
res['ai_prompt_template_id'] = default_template.id
# Update the system parameter
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
return res
def action_open_prompt_templates(self):
"""Open the prompt templates management view"""
self.ensure_one()
return {
'name': _('AI Prompt Templates'),
'type': 'ir.actions.act_window',
'res_model': 'openwebui.prompt.template',
'view_mode': 'list,form',
}
@api.model
def get_prompt_template(self, template_type='helpdesk'):
"""Get the prompt template content from the selected template or default"""
IrConfigParam = self.env['ir.config_parameter'].sudo()
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
if template_id:
try:
# Make sure the template model exists first
self.env['openwebui.prompt.template']._ensure_default_template(template_type)
# The template_id is already an integer in the database now
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
template = self.env['openwebui.prompt.template'].browse(template_id_int)
if template.exists():
return template.content
except (ValueError, TypeError) as e:
_logger.error(f"Error retrieving prompt template: {e}")
pass
# Fallback to default template
default_template = self.env['openwebui.prompt.template'].get_default_template(template_type)
return default_template.content if default_template else ""

View file

@ -1,5 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_openwebui_prompt_template_user,openwebui.prompt.template.user,model_openwebui_prompt_template,base.group_user,1,0,0,0
access_openwebui_prompt_template_admin,openwebui.prompt.template.admin,model_openwebui_prompt_template,base.group_system,1,1,1,1
access_openwebui_client_user,openwebui.client.user,model_openwebui_client,base.group_user,1,0,0,0
access_openwebui_client_admin,openwebui.client.admin,model_openwebui_client,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_openwebui_prompt_template_user openwebui.prompt.template.user model_openwebui_prompt_template base.group_user 1 0 0 0
3 access_openwebui_prompt_template_admin openwebui.prompt.template.admin model_openwebui_prompt_template base.group_system 1 1 1 1
4 access_openwebui_client_user openwebui.client.user model_openwebui_client base.group_user 1 0 0 0
5 access_openwebui_client_admin openwebui.client.admin model_openwebui_client base.group_system 1 1 1 1

View file

@ -1,109 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AI Prompt Template Form View -->
<record id="view_openai_prompt_template_form" model="ir.ui.view">
<field name="name">openwebui.prompt.template.form</field>
<field name="model">openwebui.prompt.template</field>
<field name="arch" type="xml">
<form string="AI Prompt Template">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="set_as_default" type="object"
class="oe_stat_button" icon="fa-star"
invisible="is_default">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Set as Default</span>
</div>
</button>
<button name="set_as_default" type="object"
class="oe_stat_button" icon="fa-star" disabled="1"
invisible="not is_default">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Default Template</span>
</div>
</button>
</div>
<div class="oe_title">
<h1><field name="name" placeholder="Template Name"/></h1>
</div>
<group>
<group>
<field name="template_type"/>
<field name="sequence" groups="base.group_no_one"/>
<field name="active" widget="boolean_toggle"/>
<field name="is_default" invisible="1"/>
</group>
</group>
<notebook>
<page string="Template Content" name="template_content">
<div class="text-muted mb-3">
<div invisible="template_type != 'helpdesk'">
Use placeholders like {description}, {customer}, etc. to include ticket information in the prompt.
</div>
<div invisible="template_type != 'general'">
Use placeholders like {content} to include content in the prompt.
</div>
</div>
<field name="content" widget="text" class="oe_edit_only" style="min-height: 300px;"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- AI Prompt Template List View -->
<record id="view_openai_prompt_template_list" model="ir.ui.view">
<field name="name">openwebui.prompt.template.list</field>
<field name="model">openwebui.prompt.template</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="template_type"/>
<field name="is_default" string="Default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- AI Prompt Template Search View -->
<record id="view_openai_prompt_template_search" model="ir.ui.view">
<field name="name">openwebui.prompt.template.search</field>
<field name="model">openwebui.prompt.template</field>
<field name="arch" type="xml">
<search string="Search AI Prompt Templates">
<field name="name"/>
<filter string="Default" name="default" domain="[('is_default', '=', True)]"/>
<filter string="Helpdesk" name="helpdesk" domain="[('template_type', '=', 'helpdesk')]"/>
<filter string="General" name="general" domain="[('template_type', '=', 'general')]"/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Template Type" name="group_by_type" domain="[]" context="{'group_by': 'template_type'}"/>
</group>
</search>
</field>
</record>
<!-- AI Prompt Template Action -->
<record id="action_openai_prompt_template" model="ir.actions.act_window">
<field name="name">AI Prompt Templates</field>
<field name="res_model">openwebui.prompt.template</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new AI prompt template
</p>
<p>
Define templates for AI prompts used in various modules.
</p>
</field>
</record>
<!-- Menu Item under Settings -->
<menuitem id="menu_openai_prompt_template"
name="AI Prompt Templates"
parent="base.menu_administration"
action="action_openai_prompt_template"
sequence="50"/>
</odoo>

View file

@ -1,68 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.openai.connector</field>
<field name="model">res.config.settings</field>
<field name="priority">20</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="AI Services" string="AI Services" name="openwebui_connector" logo="/base/static/description/settings.png">
<!-- API Configuration Block -->
<block title="AI API Configuration" name="openai_settings_container">
<setting id="openai_api_settings">
<span class="o_form_label">API Configuration</span>
<div class="text-muted">
Configure your AI API credentials (OpenAI, OpenWebUI, Claude)
</div>
<div class="content-group mt16">
<div class="row mt16">
<label for="openai_api_key" class="col-lg-3 o_light_label">API Key</label>
<field name="openai_api_key" password="True"/>
</div>
<div class="row mt16">
<label for="openai_base_url" class="col-lg-3 o_light_label">API Base URL</label>
<field name="openai_base_url" placeholder="https://ai.bemade.org/api"/>
</div>
<div class="row mt16">
<label for="openai_model" class="col-lg-3 o_light_label">AI Model</label>
<field name="openai_model" placeholder="anthropic.claude-3-7-sonnet-latest"/>
</div>
</div>
</setting>
</block>
<!-- Helpdesk AI Settings Block -->
<block title="Helpdesk AI Settings" name="helpdesk_ai_settings_container">
<setting id="ai_sales_order_toggle">
<field name="helpdesk_use_ai_sale_orders"/>
<label for="helpdesk_use_ai_sale_orders"/>
<div class="text-muted">
Enable AI-powered sales order generation from helpdesk tickets
</div>
</setting>
<setting id="ai_prompt_template" invisible="not helpdesk_use_ai_sale_orders">
<div class="o_setting_right_pane">
<label for="ai_prompt_template_relation"/>
<div class="text-muted">
Default template for the prompt sent to the AI
</div>
<div class="mt8 d-flex align-items-center">
<!-- Select the template directly -->
<field name="ai_prompt_template_relation"/>
<button name="action_open_prompt_templates" type="object" class="btn btn-link ps-2">
<i class="fa fa-external-link" title="Manage Templates"/> Manage Templates
</button>
</div>
<div class="mt-2 text-muted">
<small>The template content is stored in the system and can be accessed programmatically.</small>
</div>
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>