[ADD] openwebui_connector, helpdesk_sale_order_ai: AI-powered sales order generation from helpdesk tickets

This commit introduces AI integration for helpdesk tickets to automatically generate sales orders:

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

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

The integration streamlines the process of converting customer support requests into sales opportunities.
This commit is contained in:
mathis 2025-07-15 15:18:01 -04:00
parent 07c712f0b8
commit ed9dda9bea
38 changed files with 2839 additions and 118 deletions

View file

@ -1,3 +1,38 @@
# -*- coding: utf-8 -*-
from . import models
from . import views
def post_init_hook(cr, registry=None):
"""Initialize default AI prompt template after module installation
Note: This function can be called with either one argument (env) or two arguments (cr, registry)
to accommodate different Odoo hook calling conventions.
"""
import logging
_logger = logging.getLogger(__name__)
_logger.info("=== START: post_init_hook ====")
try:
from odoo import api, SUPERUSER_ID
# Handle different calling conventions
if hasattr(cr, 'env'): # cr is actually an env
env = cr
_logger.info("Using provided environment")
elif registry: # We have both cr and registry
env = api.Environment(cr, SUPERUSER_ID, {})
_logger.info("Created environment from cursor and registry")
else:
_logger.warning("Cannot create environment, skipping template creation")
return
_logger.info("Calling _ensure_default_template")
env['openwebui.prompt.template']._ensure_default_template('helpdesk')
_logger.info("Default template ensured successfully")
_logger.info("=== END: post_init_hook ====")
except Exception as e:
_logger.error(f"ERROR in post_init_hook: {str(e)}")
_logger.exception("Exception traceback:")
# Don't raise the exception to prevent installation failure
return

View file

@ -16,12 +16,16 @@
'maintainer': 'it@bemade.org',
'depends': [
'helpdesk_sale_order',
'openai_connector', # Supposant qu'un module de connexion OpenAI existe
'openwebui_connector',
],
'data': [
'security/ir.model.access.csv',
'views/helpdesk_team_views.xml',
'views/helpdesk_ticket_views.xml',
'views/res_config_settings_views.xml',
'views/ai_prompt_template_views.xml',
],
'installable': True,
'application': False,
'post_init_hook': 'post_init_hook',
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,3 +2,10 @@
from . import helpdesk_ticket
from . import helpdesk_team
from . import sale_order
# Using centralized openwebui.openwebui.client model from openwebui_connector
from . import res_config_settings
# Using centralized openwebui.prompt.template model from openwebui_connector
# Import views after all models are loaded
from .. import views

View file

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

View file

@ -7,19 +7,59 @@ class HelpdeskTeam(models.Model):
use_ai_sale_orders = fields.Boolean(
string='Use AI for Sale Orders',
help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions.',
help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions for this team. This overrides the global setting.',
default=False,
)
ai_prompt_template = fields.Text(
ai_prompt_template_id = fields.Many2one(
'openwebui.prompt.template',
domain="[('template_type', '=', 'helpdesk')]",
string='AI Prompt Template',
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
default="""Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
Ticket Description: {description}
Customer: {customer}
Please provide a list of products/services with quantities and descriptions in the following format:
Product/Service Name | Quantity | Description
"""
help='Template for the prompt sent to the AI. If empty, the global template will be used.',
)
def _get_use_ai_sale_orders(self):
"""Get whether to use AI sale orders, considering both team and global settings"""
self.ensure_one()
# If team has specific setting, use that
if self.use_ai_sale_orders:
return True
# Otherwise, check global setting
param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'False')
return param_value.lower() == 'true' if isinstance(param_value, str) else bool(param_value)
def _get_ai_prompt_template(self):
"""Get the AI prompt template to use for this team"""
self.ensure_one()
# Ensure template model is initialized
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
# If team has a specific template, use it
if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
return self.ai_prompt_template_id.content
# Otherwise, get the global default template
IrConfigParam = self.env['ir.config_parameter'].sudo()
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
if template_id:
try:
template = self.env['openwebui.prompt.template'].browse(int(template_id))
if template.exists():
return template.content
except (ValueError, TypeError):
pass
# Fallback to default template
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
return default_template.content if default_template else """
Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
Ticket Description: {description}
Customer: {customer}
Please provide a list of products/services with quantities and descriptions in the following format:
Product/Service Name | Quantity | Description
"""

View file

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

View file

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

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SaleOrder(models.Model):
_inherit = 'sale.order'
# Add missing_product_count field to avoid view errors
# This is needed because some views expect this field
missing_product_count = fields.Integer(
string='Missing Products Count',
default=0,
help='Number of products that could not be found in the database'
)
# Add has_missing_products field to avoid view errors
has_missing_products = fields.Boolean(
string='Has Missing Products',
default=False,
help='Whether this sale order has products that could not be found in the database'
)
# Add ticket_id field to link sale orders to helpdesk tickets
ticket_id = fields.Many2one(
'helpdesk.ticket',
string='Helpdesk Ticket',
readonly=True,
copy=False,
help='Helpdesk ticket from which this sale order was created'
)

View file

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_openwebui_prompt_template_helpdesk_user,openwebui.prompt.template.helpdesk.user,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
access_openwebui_prompt_template_helpdesk_manager,openwebui.prompt.template.helpdesk.manager,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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
3 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

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# These imports are handled differently in Odoo 18
# XML files are loaded automatically by the manifest

View file

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AI Prompt Template Form View -->
<record id="view_helpdesk_ai_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">
<field name="template_type" invisible="1"/>
<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"/>
</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>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- AI Prompt Template List View -->
<record id="view_helpdesk_ai_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="template_type" invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="is_default" string="Default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- AI Prompt Template Search View -->
<record id="view_helpdesk_ai_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="template_type" invisible="1"/>
<field name="name"/>
<filter string="Default" name="default" domain="[('is_default', '=', True)]"/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<!-- AI Prompt Template Action -->
<record id="action_helpdesk_ai_prompt_template" model="ir.actions.act_window">
<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="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 helpdesk tickets.
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_helpdesk_ai_prompt_template"
name="AI Prompt Templates"
parent="helpdesk.helpdesk_menu_config"
action="action_helpdesk_ai_prompt_template"
sequence="50"/>
</odoo>

View file

@ -5,9 +5,9 @@
<field name="model">helpdesk.team</field>
<field name="inherit_id" ref="helpdesk.helpdesk_team_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='team_use_sale_orders']" position="after">
<field name="use_ai_sale_orders" attrs="{'invisible': [('team_use_sale_orders', '=', False)]}"/>
<field name="ai_prompt_template" attrs="{'invisible': [('use_ai_sale_orders', '=', False)]}" widget="text_field"/>
<xpath expr="//field[@name='use_sale_orders']" position="after">
<field name="use_ai_sale_orders" invisible="not use_sale_orders"/>
<field name="ai_prompt_template_id" invisible="not use_ai_sale_orders"/>
</xpath>
</field>
</record>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="helpdesk_ticket_form_view_inherit_ai" model="ir.ui.view">
<field name="name">helpdesk.ticket.form.view.inherit.ai</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='team_id']" position="after">
<field name="team_use_sale_orders" invisible="1"/>
<field name="team_use_ai_sale_orders" invisible="1"/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="AI Suggestions" invisible="not team_use_ai_sale_orders">
<group>
<field name="ai_generated_products" widget="text" readonly="1"/>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- We need to make the field available in the form without adding UI elements -->
<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 -->
<field name="helpdesk_ai_prompt_template_id" invisible="1"/>
</form>
</field>
</record>
</odoo>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,49 @@
<?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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_openwebui_prompt_template_user,openwebui.prompt.template.user,model_openwebui_prompt_template,base.group_user,1,0,0,0
access_openwebui_prompt_template_admin,openwebui.prompt.template.admin,model_openwebui_prompt_template,base.group_system,1,1,1,1
access_openwebui_client_user,openwebui.client.user,model_openwebui_client,base.group_user,1,0,0,0
access_openwebui_client_admin,openwebui.client.admin,model_openwebui_client,base.group_system,1,1,1,1
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

@ -0,0 +1,109 @@
<?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

@ -0,0 +1,68 @@
<?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>