Compare commits

...

5 commits

Author SHA1 Message Date
mathis
014293c548 “Fix date handling and improve AI table parsing
This commit addresses two important issues in the helpdesk_sale_order_ai module:

1. Fixed date handling in sale order creation:
- Resolved timezone-related issue where dates were incorrectly shifted by one day
- Modified datetime conversion to use noon (12:00:00) instead of midnight (00:00:00)
- This provides a 12-hour buffer on either side to prevent timezone conversions from
changing the date when displayed in the UI
- Ensures extracted dates like "07/02/2025" correctly appear as "2025-07-02" in sale orders
- Prevents the previous issue where dates were being displayed as the previous day

2. Enhanced AI prompt for better table parsing:
- Enhanced product extraction from tabular data with better pattern recognition
- Improved parsing of product quantities and codes from structured table formats”
2025-08-01 14:16:16 -04:00
mathis
7815fb55a6 Refactor AI-driven sales order creation logic in helpdesk_ticket.py
- Split monolithic AI prompt into three distinct AI calls:
  1. _ai_extract_potential_products: Extract potential products from ticket content and attachments
  2. _ai_match_products_to_database: Match extracted products to database products using registered AI tools
  3. _ai_format_final_sale_order: Format matched products into final JSON structure

- Added detailed logging at each step for improved debuggability and traceability

- Fixed attribute access for OpenWebUI provider model by using provider.default_model_id.technical_name
  instead of provider.model

- Addressed type consistency issues in return statements

- Refactored _get_sale_order_values method to orchestrate these three steps sequentially,
  maintaining existing functionality while improving modularity and debuggability

- Modified _ai_extract_potential_products method to use 'product_code' field for product references/codes
  instead of putting them in the 'name' field

- Updated _ai_match_products_to_database to properly handle the new field structure

- Fixed KeyError in template formatting by implementing safe template substitution using string.Template
  with safe_substitute() method to handle missing placeholders gracefully

- Updated AI integration to use openwebui_base module directly instead of bridge model approach
2025-07-31 13:57:58 -04:00
mathis
794e800d7c Fix source information message in sale order chatter and attach original files 2025-07-29 10:31:23 -04:00
mathis
c5904a51d7 [REF] Replace openwebui_connector with centralized openwebui_base module
This commit represents a significant architectural improvement:

- Completely replaced openwebui_connector with a more robust openwebui_base module
- Centralized OpenWebUI API integration for better maintainability
- Redesigned helpdesk_sale_order_ai to use the new openwebui_base module
- Added support for multiple OpenWebUI providers and models
- Improved error handling and response parsing
- Added proper template management with openwebui_prompt_template
- Fixed KeyError issues with safe template substitution
- Streamlined API client initialization and usage
2025-07-28 13:27:29 -04:00
mathis
ed9dda9bea [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.
2025-07-15 15:18:01 -04:00
32 changed files with 2094 additions and 140 deletions

View file

@ -1,3 +1,39 @@
# -*- 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")
# Use our bridge model which will call the correct underlying model
env['ai.openwebui.prompt.template']._ensure_default_template('helpdesk')
_logger.info("Default template ensured successfully")
_logger.info("=== END: post_init_hook ====")
except Exception as e:
_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,17 @@
'maintainer': 'it@bemade.org',
'depends': [
'helpdesk_sale_order',
'openai_connector', # Supposant qu'un module de connexion OpenAI existe
'openwebui_base',
],
'data': [
'security/ir.model.access.csv',
'views/helpdesk_team_views.xml',
'views/helpdesk_ticket_views.xml',
'views/helpdesk_ticket_button_views.xml',
'views/res_config_settings_views.xml',
'views/ai_prompt_template_views.xml',
],
'installable': True,
'application': False,
'post_init_hook': 'post_init_hook',
}

View file

@ -2,3 +2,10 @@
from . import helpdesk_ticket
from . import helpdesk_team
from . import sale_order
from . import ai_openwebui_client # Bridge model for OpenWebUI client
from . import ai_openwebui_prompt_template # Bridge model for OpenWebUI prompt templates
from . import res_config_settings
# Import views after all models are loaded
from .. import views

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# Inherit the fields from openwebui_base module
# These fields are already defined in the openwebui_base module
# Add a field to select the prompt template directly
helpdesk_ai_prompt_template_id = fields.Many2one(
'openwebui.prompt.template',
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))
@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['ai.openwebui.prompt.template']._ensure_default_template('helpdesk')
# The template_id is already an integer in the database now
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
template = self.env['ai.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['ai.openwebui.prompt.template'].get_default_template('helpdesk')
return default_template.content if default_template else ""

View file

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

View file

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

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,82 @@
<?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="0"/>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="Template Name"/></h1>
</div>
<group>
<group>
<field name="is_default"/>
</group>
</group>
<notebook>
<page string="Template Content">
<field name="content" nolabel="1" placeholder="Enter the prompt template content here..."/>
</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"/>
<field name="name"/>
<field name="is_default" string="Default"/>
</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"/>
<field name="name"/>
<filter string="Default" name="default" domain="[('is_default', '=', True)]"/>
<filter string="Helpdesk Templates" name="helpdesk" domain="[('template_type', '=', 'helpdesk')]"/>
<filter string="General Templates" name="general" domain="[('template_type', '=', 'general')]"/>
<filter string="Custom Templates" name="custom" domain="[('template_type', '=', 'custom')]"/>
</search>
</field>
</record>
<!-- 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="search_view_id" ref="view_helpdesk_ai_prompt_template_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first AI prompt template!
</p>
<p>
AI prompt templates are used to generate product suggestions from 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,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="helpdesk_ticket_form_view_inherit_ai_button" model="ir.ui.view">
<field name="name">helpdesk.ticket.form.view.inherit.ai.button</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk_sale_order.helpdesk_ticket_form_view_inherit_saleorder"/>
<field name="arch" type="xml">
<!-- Update the Convert to Quotation button to be visible when either team_use_sale_orders OR team_use_ai_sale_orders is True -->
<xpath expr="//button[@name='action_convert_to_sale_order']" position="attributes">
<attribute name="invisible">not team_use_sale_orders and not team_use_ai_sale_orders</attribute>
</xpath>
</field>
</record>
</odoo>

View file

@ -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,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add the hidden field to the base settings form -->
<record id="res_config_settings_view_form_inherit_helpdesk_sale_order_ai" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.helpdesk.sale.order.ai</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<!-- Add the hidden field at the root level of the form -->
<field name="company_id" position="after">
<field name="helpdesk_ai_prompt_template_id" invisible="1"/>
</field>
</field>
</record>
</odoo>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

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

After

Width:  |  Height:  |  Size: 359 B

View file

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

View file

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

View file

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

View file

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

View file

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