[REF] Replace openwebui_connector with centralized openwebui_base module
This commit represents a significant architectural improvement: - Completely replaced openwebui_connector with a more robust openwebui_base module - Centralized OpenWebUI API integration for better maintainability - Redesigned helpdesk_sale_order_ai to use the new openwebui_base module - Added support for multiple OpenWebUI providers and models - Improved error handling and response parsing - Added proper template management with openwebui_prompt_template - Fixed KeyError issues with safe template substitution - Streamlined API client initialization and usage
This commit is contained in:
parent
ed9dda9bea
commit
c5904a51d7
55 changed files with 2738 additions and 2526 deletions
|
|
@ -28,7 +28,8 @@ def post_init_hook(cr, registry=None):
|
|||
return
|
||||
|
||||
_logger.info("Calling _ensure_default_template")
|
||||
env['openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
# Use our bridge model which will call the correct underlying model
|
||||
env['ai.openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
_logger.info("Default template ensured successfully")
|
||||
_logger.info("=== END: post_init_hook ====")
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -16,12 +16,13 @@
|
|||
'maintainer': 'it@bemade.org',
|
||||
'depends': [
|
||||
'helpdesk_sale_order',
|
||||
'openwebui_connector',
|
||||
'openwebui_base',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/helpdesk_team_views.xml',
|
||||
'views/helpdesk_ticket_views.xml',
|
||||
'views/helpdesk_ticket_button_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/ai_prompt_template_views.xml',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"OpenWebUI": {
|
||||
"api_key": "",
|
||||
"base_url": "https://ai.bemade.org/api",
|
||||
"model": "anthropic.claude-3-7-sonnet-latest"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is intentionally left empty to make the directory a proper Python package
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run after the module update
|
||||
# It will ensure the prompt templates are properly set up
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Ensure prompt templates are properly set up after migration
|
||||
"""
|
||||
# Check if we have any prompt templates
|
||||
cr.execute("""
|
||||
SELECT COUNT(*) FROM helpdesk_ai_prompt_template
|
||||
""")
|
||||
|
||||
template_count = cr.fetchone()[0]
|
||||
|
||||
if template_count == 0:
|
||||
# Create a default template if none exists
|
||||
_logger.info("No prompt templates found, creating default template")
|
||||
|
||||
default_template_name = "Default Sales Order Template"
|
||||
default_template_content = """
|
||||
You are a sales order assistant. Your task is to analyze the customer's request and suggest products or services that would meet their needs.
|
||||
|
||||
Please provide a list of products or services in the following format:
|
||||
1. Product Name | Quantity | Description
|
||||
2. Product Name | Quantity | Description
|
||||
|
||||
If you're not sure about a specific product, suggest a generic service item with a description of what it should accomplish.
|
||||
"""
|
||||
|
||||
# Insert the default template
|
||||
cr.execute("""
|
||||
INSERT INTO helpdesk_ai_prompt_template (name, content, create_date, write_date)
|
||||
VALUES (%s, %s, now(), now())
|
||||
RETURNING id
|
||||
""", (default_template_name, default_template_content))
|
||||
|
||||
template_id = cr.fetchone()[0]
|
||||
|
||||
# Set this as the default template
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (str(template_id),))
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
|
||||
'Post-migration: Created default prompt template with ID ' || %s,
|
||||
'/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 50, 'migrate')
|
||||
""", (str(template_id),))
|
||||
else:
|
||||
# Check if we have a default template set
|
||||
cr.execute("""
|
||||
SELECT value FROM ir_config_parameter
|
||||
WHERE key = 'helpdesk_sale_order_ai.default_prompt_template_id'
|
||||
""")
|
||||
|
||||
default_template_id = cr.fetchone()
|
||||
|
||||
if not default_template_id:
|
||||
# Get the first template and set it as default
|
||||
cr.execute("""
|
||||
SELECT id FROM helpdesk_ai_prompt_template
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
template_id = cr.fetchone()[0]
|
||||
|
||||
# Set this as the default template
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (str(template_id),))
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
|
||||
'Post-migration: Set existing template with ID ' || %s || ' as default',
|
||||
'/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 75, 'migrate')
|
||||
""", (str(template_id),))
|
||||
else:
|
||||
_logger.info(f"Default prompt template already set: {default_template_id[0]}")
|
||||
|
||||
# Ensure the system parameters are properly set up
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('openai.base_url', 'https://ai.bemade.org/api', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('openai.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Migrate helpdesk.ai.prompt.template records to openwebui.prompt.template
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
|
||||
_logger.info("Starting migration of AI prompt templates")
|
||||
|
||||
# Check if the old model exists
|
||||
cr.execute("SELECT 1 FROM ir_model WHERE model = 'helpdesk.ai.prompt.template'")
|
||||
if not cr.fetchone():
|
||||
_logger.info("No helpdesk.ai.prompt.template model found, skipping migration")
|
||||
return
|
||||
|
||||
# Check if the new model exists
|
||||
cr.execute("SELECT 1 FROM ir_model WHERE model = 'openwebui.prompt.template'")
|
||||
if not cr.fetchone():
|
||||
_logger.warning("openwebui.prompt.template model not found, cannot migrate")
|
||||
return
|
||||
|
||||
# Get all templates from the old model
|
||||
cr.execute("""
|
||||
SELECT id, name, content, is_default, active, sequence
|
||||
FROM helpdesk_ai_prompt_template
|
||||
""")
|
||||
old_templates = cr.fetchall()
|
||||
|
||||
if not old_templates:
|
||||
_logger.info("No templates found in helpdesk.ai.prompt.template, skipping migration")
|
||||
return
|
||||
|
||||
_logger.info(f"Found {len(old_templates)} templates to migrate")
|
||||
|
||||
# For each template, create a corresponding record in the new model
|
||||
for template_id, name, content, is_default, active, sequence in old_templates:
|
||||
# Check if a template with the same name already exists in the new model
|
||||
cr.execute("""
|
||||
SELECT id FROM openwebui_prompt_template
|
||||
WHERE name = %s AND template_type = 'helpdesk'
|
||||
""", (name,))
|
||||
existing = cr.fetchone()
|
||||
|
||||
if existing:
|
||||
_logger.info(f"Template '{name}' already exists in openwebui.prompt.template, updating")
|
||||
cr.execute("""
|
||||
UPDATE openwebui_prompt_template
|
||||
SET content = %s, is_default = %s, active = %s, sequence = %s
|
||||
WHERE id = %s
|
||||
""", (content, is_default, active, sequence, existing[0]))
|
||||
else:
|
||||
_logger.info(f"Creating new template '{name}' in openwebui.prompt.template")
|
||||
cr.execute("""
|
||||
INSERT INTO openwebui_prompt_template
|
||||
(name, content, is_default, active, sequence, template_type, create_date, write_date)
|
||||
VALUES (%s, %s, %s, %s, %s, 'helpdesk', now(), now())
|
||||
""", (name, content, is_default, active, sequence))
|
||||
|
||||
# Update system parameters that reference the old model
|
||||
cr.execute("""
|
||||
SELECT key, value FROM ir_config_parameter
|
||||
WHERE key LIKE 'helpdesk_sale_order_ai.%_prompt_template_id'
|
||||
""")
|
||||
params = cr.fetchall()
|
||||
|
||||
for key, value in params:
|
||||
if value and value.isdigit():
|
||||
# Get the name of the template
|
||||
cr.execute("""
|
||||
SELECT name FROM helpdesk_ai_prompt_template
|
||||
WHERE id = %s
|
||||
""", (int(value),))
|
||||
template_name = cr.fetchone()
|
||||
|
||||
if template_name:
|
||||
# Find the corresponding template in the new model
|
||||
cr.execute("""
|
||||
SELECT id FROM openwebui_prompt_template
|
||||
WHERE name = %s AND template_type = 'helpdesk'
|
||||
""", (template_name[0],))
|
||||
new_template_id = cr.fetchone()
|
||||
|
||||
if new_template_id:
|
||||
new_key = key.replace('helpdesk_sale_order_ai', 'openwebui_connector')
|
||||
_logger.info(f"Updating system parameter {key} to {new_key} with value {new_template_id[0]}")
|
||||
|
||||
# Check if the new parameter already exists
|
||||
cr.execute("""
|
||||
SELECT id FROM ir_config_parameter
|
||||
WHERE key = %s
|
||||
""", (new_key,))
|
||||
existing_param = cr.fetchone()
|
||||
|
||||
if existing_param:
|
||||
cr.execute("""
|
||||
UPDATE ir_config_parameter
|
||||
SET value = %s
|
||||
WHERE id = %s
|
||||
""", (str(new_template_id[0]), existing_param[0]))
|
||||
else:
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value)
|
||||
VALUES (%s, %s)
|
||||
""", (new_key, str(new_template_id[0])))
|
||||
|
||||
_logger.info("Migration of AI prompt templates completed")
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Migrate system parameters from helpdesk OpenWebUI client to centralized OpenAI client
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
|
||||
_logger.info("Starting migration of OpenWebUI configuration parameters")
|
||||
|
||||
# Map of old parameter keys to new parameter keys
|
||||
param_mapping = {
|
||||
'openwebui.api_key': 'openai.api_key',
|
||||
'openwebui.base_url': 'openai.base_url',
|
||||
'openwebui.model': 'openai.model',
|
||||
}
|
||||
|
||||
# For each parameter, check if it exists and migrate it if needed
|
||||
for old_key, new_key in param_mapping.items():
|
||||
# Check if the old parameter exists
|
||||
cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (old_key,))
|
||||
old_value = cr.fetchone()
|
||||
|
||||
if old_value and old_value[0]:
|
||||
# Check if the new parameter already exists
|
||||
cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (new_key,))
|
||||
new_value = cr.fetchone()
|
||||
|
||||
if not new_value or not new_value[0]:
|
||||
# New parameter doesn't exist or is empty, set it to the old value
|
||||
_logger.info(f"Migrating parameter {old_key} to {new_key} with value {old_value[0]}")
|
||||
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (new_key, old_value[0]))
|
||||
|
||||
_logger.info("Migration of OpenWebUI configuration parameters completed")
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is intentionally left empty to make the directory a proper Python package
|
||||
|
|
@ -3,9 +3,9 @@
|
|||
from . import helpdesk_ticket
|
||||
from . import helpdesk_team
|
||||
from . import sale_order
|
||||
# Using centralized openwebui.openwebui.client model from openwebui_connector
|
||||
from . import ai_openwebui_client # Bridge model for OpenWebUI client
|
||||
from . import ai_openwebui_prompt_template # Bridge model for OpenWebUI prompt templates
|
||||
from . import res_config_settings
|
||||
# Using centralized openwebui.prompt.template model from openwebui_connector
|
||||
|
||||
# Import views after all models are loaded
|
||||
from .. import views
|
||||
|
|
|
|||
114
helpdesk_sale_order_ai/models/ai_openwebui_client.py
Normal file
114
helpdesk_sale_order_ai/models/ai_openwebui_client.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class AIPromptTemplate(models.Model):
|
||||
_name = 'helpdesk.ai.prompt.template'
|
||||
_description = 'AI Prompt Template'
|
||||
_rec_name = 'name'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
is_default = fields.Boolean(
|
||||
string='Is Default',
|
||||
default=False,
|
||||
)
|
||||
content = fields.Text(
|
||||
string='Template Content',
|
||||
required=True,
|
||||
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _ensure_default_template(self):
|
||||
"""Ensure there's at least one default template"""
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("=== START: _ensure_default_template ====")
|
||||
|
||||
try:
|
||||
_logger.info("Searching for default template")
|
||||
default_template = self.search([('is_default', '=', True)], limit=1)
|
||||
_logger.info(f"Default template search result: {default_template}")
|
||||
|
||||
if not default_template:
|
||||
_logger.info("No default template found, creating one")
|
||||
# Create a default template if none exists
|
||||
default_content = """Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
|
||||
|
||||
Ticket Description: {description}
|
||||
Customer: {customer}
|
||||
|
||||
Please provide a list of products/services with quantities and descriptions in the following format:
|
||||
Product/Service Name | Quantity | Description
|
||||
"""
|
||||
_logger.info("Creating default template")
|
||||
new_template = self.create({
|
||||
'name': 'Default Template',
|
||||
'content': default_content,
|
||||
'is_default': True,
|
||||
})
|
||||
_logger.info(f"Created default template: {new_template}")
|
||||
else:
|
||||
_logger.info(f"Found existing default template: {default_template.name}")
|
||||
|
||||
_logger.info("=== END: _ensure_default_template ====")
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.error(f"ERROR in _ensure_default_template: {str(e)}")
|
||||
_logger.exception("Exception traceback:")
|
||||
raise
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to handle default templates"""
|
||||
records = super(AIPromptTemplate, self).create(vals_list)
|
||||
# If any new template is set as default, unset default flag on others
|
||||
default_templates = records.filtered(lambda r: r.is_default)
|
||||
if default_templates:
|
||||
self.search([('id', 'not in', default_templates.ids), ('is_default', '=', True)]).write({'is_default': False})
|
||||
return records
|
||||
|
||||
@api.model
|
||||
def get_default_template(self):
|
||||
"""Get the default template"""
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("=== START: get_default_template ====")
|
||||
|
||||
try:
|
||||
_logger.info("Searching for default template")
|
||||
default_template = self.search([('is_default', '=', True)], limit=1)
|
||||
_logger.info(f"Default template search result: {default_template}")
|
||||
|
||||
if not default_template:
|
||||
_logger.info("No default template found, ensuring one exists")
|
||||
self._ensure_default_template()
|
||||
default_template = self.search([('is_default', '=', True)], limit=1)
|
||||
_logger.info(f"Default template after ensure: {default_template}")
|
||||
|
||||
_logger.info("=== END: get_default_template ====")
|
||||
return default_template
|
||||
except Exception as e:
|
||||
_logger.error(f"ERROR in get_default_template: {str(e)}")
|
||||
_logger.exception("Exception traceback:")
|
||||
return self.browse()
|
||||
|
||||
def set_as_default(self):
|
||||
"""Set this template as the default"""
|
||||
if self:
|
||||
# Clear default flag on all other templates
|
||||
self.search([('id', '!=', self.id)]).write({'is_default': False})
|
||||
# Set this template as default
|
||||
self.write({'is_default': True})
|
||||
return True
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HelpdeskTeam(models.Model):
|
||||
|
|
@ -25,40 +28,40 @@ class HelpdeskTeam(models.Model):
|
|||
if self.use_ai_sale_orders:
|
||||
return True
|
||||
|
||||
# Otherwise, check global setting
|
||||
param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'False')
|
||||
# Otherwise, check global setting (default to True)
|
||||
param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'True')
|
||||
return param_value.lower() == 'true' if isinstance(param_value, str) else bool(param_value)
|
||||
|
||||
def _get_ai_prompt_template(self):
|
||||
"""Get the AI prompt template to use for this team"""
|
||||
self.ensure_one()
|
||||
|
||||
# Ensure template model is initialized
|
||||
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
|
||||
# If team has a specific template, use it
|
||||
if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
|
||||
return self.ai_prompt_template_id.content
|
||||
try:
|
||||
# Ensure template model is initialized
|
||||
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
|
||||
# Otherwise, get the global default template
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
template = self.env['openwebui.prompt.template'].browse(int(template_id))
|
||||
if template.exists():
|
||||
return template.content
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# If team has a specific template, use it
|
||||
if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
|
||||
_logger.info(f"Using team-specific prompt template: {self.ai_prompt_template_id.name}")
|
||||
return self.ai_prompt_template_id.content
|
||||
|
||||
# Fallback to default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
return default_template.content if default_template else """
|
||||
# Otherwise, get the global default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
_logger.info(f"Using default helpdesk prompt template: {default_template.name}")
|
||||
return default_template.content
|
||||
except Exception as e:
|
||||
_logger.error(f"Error retrieving prompt template: {str(e)}")
|
||||
|
||||
# Fallback to hardcoded template if all else fails
|
||||
_logger.warning("Using fallback hardcoded prompt template")
|
||||
return """
|
||||
Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
|
||||
|
||||
Ticket Description: {description}
|
||||
Customer: {customer}
|
||||
Chatter Messages: {chatter_messages}
|
||||
Attachments: {attachments_info}
|
||||
Attachment Contents: {attachment_contents}
|
||||
|
||||
Please provide a list of products/services with quantities and descriptions in the following format:
|
||||
Product/Service Name | Quantity | Description
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ from odoo.exceptions import UserError
|
|||
import logging
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Import the client model to ensure it's loaded
|
||||
from . import ai_openwebui_client
|
||||
|
||||
|
||||
class HelpdeskTicket(models.Model):
|
||||
_inherit = 'helpdesk.ticket'
|
||||
|
|
@ -46,157 +51,31 @@ class HelpdeskTicket(models.Model):
|
|||
def _ai_convert_to_sale_order(self):
|
||||
"""Create a sale order using AI to suggest products based on ticket description"""
|
||||
self.ensure_one()
|
||||
|
||||
values = self._get_sale_order_values()
|
||||
values["partner_id"] = self.partner_id.id
|
||||
|
||||
_logger.info("Starting AI conversion to sale order for ticket %s", self.id)
|
||||
# Add debug logging
|
||||
_logger.debug(f"Sale order values before create: {values}")
|
||||
_logger.debug(f"date_order type: {type(values.get('date_order'))}")
|
||||
|
||||
# Always generate fresh AI suggestions
|
||||
_logger.info("Generating fresh AI suggestions for ticket %s", self.id)
|
||||
result = self._generate_ai_product_suggestions()
|
||||
_logger.info("AI suggestion generation result for ticket %s: %s", self.id, result)
|
||||
|
||||
# Get base values for sale order (partner, pricelist, etc.)
|
||||
partner_id = self.partner_id.id
|
||||
partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice']
|
||||
partner_shipping_id = self.partner_id.address_get(['delivery'])['delivery']
|
||||
|
||||
# Parse AI suggestions to get order lines and sale order fields
|
||||
ai_data = {'order_lines': [], 'sale_order_fields': {}}
|
||||
if self.ai_generated_products:
|
||||
_logger.info("AI suggestions found for ticket %s, parsing them now: %s", self.id, self.ai_generated_products[:200])
|
||||
ai_data = self._parse_ai_product_suggestions()
|
||||
_logger.info("Parsed AI data: %s", ai_data)
|
||||
|
||||
# Prepare sale order values
|
||||
so_values = {
|
||||
'partner_id': partner_id,
|
||||
'partner_invoice_id': partner_invoice_id,
|
||||
'partner_shipping_id': partner_shipping_id,
|
||||
'ticket_id': self.id,
|
||||
'origin': self.name,
|
||||
'note': self.description,
|
||||
}
|
||||
|
||||
# Add AI-extracted fields to sale order values if available
|
||||
if ai_data.get('sale_order_fields'):
|
||||
so_fields = ai_data['sale_order_fields']
|
||||
|
||||
# Client order reference (PO number)
|
||||
if so_fields.get('client_order_ref'):
|
||||
so_values['client_order_ref'] = so_fields['client_order_ref']
|
||||
_logger.info(f"Setting client_order_ref to: {so_fields['client_order_ref']}")
|
||||
|
||||
# Order date
|
||||
if so_fields.get('date_order'):
|
||||
try:
|
||||
# Validate date format
|
||||
from datetime import datetime
|
||||
date_order = datetime.strptime(so_fields['date_order'], '%Y-%m-%d')
|
||||
so_values['date_order'] = date_order
|
||||
_logger.info(f"Setting date_order to: {so_fields['date_order']}")
|
||||
except (ValueError, TypeError) as e:
|
||||
_logger.warning(f"Invalid date_order format: {so_fields['date_order']}, error: {e}")
|
||||
|
||||
# Commitment date (delivery date)
|
||||
if so_fields.get('commitment_date'):
|
||||
try:
|
||||
# Validate date format
|
||||
from datetime import datetime
|
||||
commitment_date = datetime.strptime(so_fields['commitment_date'], '%Y-%m-%d')
|
||||
so_values['commitment_date'] = commitment_date
|
||||
_logger.info(f"Setting commitment_date to: {so_fields['commitment_date']}")
|
||||
except (ValueError, TypeError) as e:
|
||||
_logger.warning(f"Invalid commitment_date format: {so_fields['commitment_date']}, error: {e}")
|
||||
|
||||
# Note (special instructions)
|
||||
if so_fields.get('note'):
|
||||
# Append to existing note if any
|
||||
existing_note = so_values.get('note', '')
|
||||
if existing_note:
|
||||
so_values['note'] = f"{existing_note}\n\n{so_fields['note']}"
|
||||
else:
|
||||
so_values['note'] = so_fields['note']
|
||||
_logger.info(f"Setting note to: {so_values['note'][:100]}...")
|
||||
|
||||
# Payment terms
|
||||
if so_fields.get('payment_term_id'):
|
||||
# Try to find matching payment term
|
||||
payment_term_name = so_fields['payment_term_id']
|
||||
payment_term = self.env['account.payment.term'].search(
|
||||
['|', ('name', '=', payment_term_name), ('name', 'ilike', payment_term_name)], limit=1)
|
||||
if payment_term:
|
||||
so_values['payment_term_id'] = payment_term.id
|
||||
_logger.info(f"Setting payment_term_id to: {payment_term.name} (ID: {payment_term.id})")
|
||||
else:
|
||||
_logger.warning(f"Payment term not found: {payment_term_name}")
|
||||
# Ensure date_order is set and is a datetime object
|
||||
if 'date_order' not in values or not values['date_order']:
|
||||
from datetime import datetime
|
||||
values['date_order'] = datetime.now()
|
||||
_logger.debug(f"Setting default date_order: {values['date_order']}")
|
||||
|
||||
# Fix empty string dates by converting them to None
|
||||
# This prevents PostgreSQL errors with empty string timestamps
|
||||
date_fields = ['date_order', 'commitment_date', 'validity_date']
|
||||
for field in date_fields:
|
||||
if field in values and values[field] == '':
|
||||
values[field] = None
|
||||
_logger.debug(f"Converting empty {field} to None")
|
||||
|
||||
# Create the sale order
|
||||
sale_order = self.env['sale.order'].create(so_values)
|
||||
_logger.info(f"Created sale order with ID {sale_order.id}")
|
||||
|
||||
# Add order lines to the sale order
|
||||
order_lines = ai_data.get('order_lines', [])
|
||||
_logger.info("Adding %d order lines to sale order %s", len(order_lines), sale_order.id)
|
||||
|
||||
for line in order_lines:
|
||||
# Each line is a tuple (0, 0, values_dict)
|
||||
# Extract the values dict
|
||||
line_values = line[2]
|
||||
_logger.info("Creating order line with values: %s", line_values)
|
||||
# Create a new order line that will trigger price computation
|
||||
# Include the price_unit from the parsed data if available
|
||||
initial_values = {
|
||||
'order_id': sale_order.id,
|
||||
'product_id': line_values.get('product_id'),
|
||||
'product_uom_qty': line_values.get('product_uom_qty'),
|
||||
'name': line_values.get('name'),
|
||||
}
|
||||
|
||||
# Get the price that was calculated in _create_product_order_line
|
||||
calculated_price = line_values.get('price_unit')
|
||||
if calculated_price is not None:
|
||||
_logger.info(f"Using pre-calculated price: {calculated_price} for product ID: {line_values.get('product_id')}")
|
||||
initial_values['price_unit'] = calculated_price
|
||||
|
||||
order_line = self.env['sale.order.line'].new(initial_values)
|
||||
|
||||
# Trigger standard Odoo onchange to compute prices
|
||||
try:
|
||||
# This is the main onchange that should set the price based on product and pricelist
|
||||
order_line._onchange_product_id()
|
||||
|
||||
# Log the computed price for debugging
|
||||
_logger.info(f"Standard Odoo price computation: {order_line.price_unit} for product {order_line.product_id.name}")
|
||||
|
||||
# If price is still 0 and product has a list price, use that as fallback
|
||||
if order_line.price_unit == 0 and order_line.product_id.list_price > 0:
|
||||
order_line.price_unit = order_line.product_id.list_price
|
||||
_logger.info(f"Price was 0, using product list price: {order_line.price_unit}")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in standard price computation: {str(e)}")
|
||||
# Continue with creation even if price computation fails
|
||||
|
||||
# Create a clean dict with only the necessary values
|
||||
order_line_values = {
|
||||
'order_id': sale_order.id,
|
||||
'product_id': order_line.product_id.id,
|
||||
'product_uom_qty': order_line.product_uom_qty,
|
||||
'name': order_line.name,
|
||||
'price_unit': order_line.price_unit,
|
||||
}
|
||||
|
||||
# Add product_uom if it exists
|
||||
if order_line.product_uom:
|
||||
order_line_values['product_uom'] = order_line.product_uom.id
|
||||
|
||||
# Create the actual order line with computed prices
|
||||
self.env['sale.order.line'].create(order_line_values)
|
||||
|
||||
# Link the sale order to the ticket
|
||||
self.write({
|
||||
'sale_order_id': sale_order.id,
|
||||
})
|
||||
|
||||
# Return the action to view the created sale order
|
||||
sale_order = self.env['sale.order'].create(values)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sale Order'),
|
||||
|
|
@ -205,476 +84,278 @@ class HelpdeskTicket(models.Model):
|
|||
'view_mode': 'form,list',
|
||||
'context': self.env.context,
|
||||
}
|
||||
|
||||
def _generate_ai_product_suggestions(self):
|
||||
"""Use AI to generate product suggestions based on ticket description, chatter messages and attachments"""
|
||||
|
||||
def _get_sale_order_values(self) -> dict:
|
||||
"""
|
||||
Generate sales order values using AI to analyze ticket content, chatter messages, and attachments.
|
||||
The AI will identify products from the content and match them to Odoo products using the helper methods.
|
||||
|
||||
Returns:
|
||||
dict: Values for creating a sales order including order lines
|
||||
"""
|
||||
self.ensure_one()
|
||||
_logger.info(f"Generating AI sales order values for ticket {self.id}")
|
||||
|
||||
_logger.info("Generating AI product suggestions for ticket %s", self.id)
|
||||
# Get the ticket data
|
||||
ticket_data = self._prepare_ai_prompt_data()
|
||||
description = ticket_data.get('ticket_description', '')
|
||||
chatter_messages = ticket_data.get('ticket_messages', '')
|
||||
attachments_info = ticket_data.get('attachments_info', '')
|
||||
attachment_contents = ticket_data.get('attachment_contents', '')
|
||||
|
||||
# Get the ticket description
|
||||
description = self.description or ""
|
||||
# Log the content being analyzed
|
||||
_logger.info(f"AI Analysis - Content lengths: Description={len(description)}, Chatter={len(chatter_messages)}, Attachments={len(attachment_contents)}")
|
||||
|
||||
# If description is empty, try to use the name
|
||||
if not description.strip():
|
||||
description = self.name or ""
|
||||
# Get the OpenWebUI provider from company settings
|
||||
company = self.env.company
|
||||
provider = company.openwebui_provider_id
|
||||
|
||||
# Get chatter messages
|
||||
chatter_messages = ""
|
||||
if self.message_ids:
|
||||
for message in self.message_ids:
|
||||
if message.body and not message.is_internal:
|
||||
# Extract text from HTML
|
||||
body_text = re.sub(r'<[^>]+>', ' ', message.body)
|
||||
chatter_messages += f"Message from {message.author_id.name or 'Unknown'}: {body_text}\n\n"
|
||||
if not provider:
|
||||
_logger.error("No OpenWebUI provider configured for company")
|
||||
return {"order_line": []}
|
||||
|
||||
# Get the OpenWebUI client from the provider
|
||||
ai_client = provider.get_client()
|
||||
|
||||
# Register the product finding methods as tools
|
||||
registry = ai_client.tool_registry
|
||||
|
||||
registry.register(
|
||||
self._ai_find_product_id_by_name,
|
||||
non_ai_params=["self"],
|
||||
description="Find a product by its name and return its ID. Input: name (string) - The name of the product to find. Returns the product ID if found, or null if not found."
|
||||
)
|
||||
registry.register(
|
||||
self._ai_find_product_id_by_code,
|
||||
non_ai_params=["self"],
|
||||
description="Find a product by its code/reference and return its ID. Input: code (string) - The code/reference of the product to find. Returns the product ID if found, or null if not found."
|
||||
)
|
||||
|
||||
# Get attachments
|
||||
attachments_info = ""
|
||||
attachment_contents = ""
|
||||
if self.message_ids:
|
||||
for message in self.message_ids:
|
||||
if message.attachment_ids:
|
||||
for attachment in message.attachment_ids:
|
||||
attachments_info += f"Attachment: {attachment.name} ({attachment.mimetype})\n"
|
||||
|
||||
# Extract text from PDF attachments
|
||||
if attachment.mimetype == 'application/pdf' and attachment.datas:
|
||||
try:
|
||||
import base64
|
||||
import io
|
||||
|
||||
# Try to use PyPDF2 if available
|
||||
try:
|
||||
from PyPDF2 import PdfReader
|
||||
|
||||
pdf_data = base64.b64decode(attachment.datas)
|
||||
pdf_file = io.BytesIO(pdf_data)
|
||||
pdf_reader = PdfReader(pdf_file)
|
||||
|
||||
pdf_text = ""
|
||||
for page_num in range(len(pdf_reader.pages)): # Process all pages
|
||||
page = pdf_reader.pages[page_num]
|
||||
pdf_text += page.extract_text() + "\n"
|
||||
|
||||
attachment_contents += f"Content from {attachment.name}:\n{pdf_text}\n\n" # Include full text
|
||||
except ImportError:
|
||||
_logger.warning("PyPDF2 not available, skipping PDF text extraction")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error extracting text from PDF: {str(e)}")
|
||||
|
||||
# If everything is empty, show error
|
||||
if not description.strip() and not chatter_messages.strip() and not attachment_contents.strip():
|
||||
_logger.error("No content available for AI analysis")
|
||||
return False
|
||||
attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)])
|
||||
attachments_list = []
|
||||
|
||||
# Process PDF files for analysis
|
||||
for attachment in attachments:
|
||||
if attachment.mimetype == 'application/pdf':
|
||||
try:
|
||||
temp_path = f"/tmp/{attachment.name}"
|
||||
shutil.copy(attachment._full_path(attachment.store_fname), temp_path)
|
||||
attachments_list.append(Path(temp_path))
|
||||
except Exception as e:
|
||||
_logger.error(f"Error processing attachment {attachment.name}: {e}")
|
||||
|
||||
# Create the prompt for the AI
|
||||
prompt = f"""You are an expert sales assistant for a pneumatic automation company.
|
||||
Your task is to analyze the customer request and suggest appropriate products or services.
|
||||
prompt = f"""IMPORTANT: YOU ARE NOT A CONVERSATIONAL ASSISTANT. YOU ARE A DATA EXTRACTION SYSTEM.
|
||||
|
||||
Your ONLY function is to analyze the provided content and return a structured JSON object so that later it can be used to create a sales order.
|
||||
DO NOT introduce yourself, explain what you can or cannot do, or engage in conversation.
|
||||
ONLY RETURN THE REQUESTED JSON DATA STRUCTURE.
|
||||
|
||||
TASK: Extract product information and sales order details from the following content:
|
||||
|
||||
Customer Request:
|
||||
{description}
|
||||
|
||||
Chatter Messages:
|
||||
Chatter Messages (IMPORTANT - CAREFULLY ANALYZE THESE FOR PRODUCT INFORMATION):
|
||||
{chatter_messages}
|
||||
|
||||
Attachments Information:
|
||||
{attachments_info}
|
||||
|
||||
Attachment Contents:
|
||||
Attachment Contents (CAREFULLY ANALYZE PDF CONTENTS FOR PRODUCT DETAILS):
|
||||
{attachment_contents}
|
||||
|
||||
Based on this information, please perform two tasks:
|
||||
WORKFLOW - FOLLOW THESE STEPS EXACTLY:
|
||||
1. Identify all products mentioned in the content (product names, codes, references)
|
||||
2. For EACH product identified:
|
||||
a. If you find a product code/reference, call _ai_find_product_id_by_code with that code
|
||||
b. If you only have a product name, call _ai_find_product_id_by_name with that name
|
||||
c. Store the returned product ID (or note if not found)
|
||||
3. Extract order details (client reference, dates, notes)
|
||||
4. Construct the JSON response using the product IDs you obtained from tool calls
|
||||
|
||||
1. Map the following Odoo sale order fields from the information provided:
|
||||
- client_order_ref: Customer's reference/PO number
|
||||
- date_order: Order date (in YYYY-MM-DD format)
|
||||
- commitment_date: Delivery date (in YYYY-MM-DD format)
|
||||
- note: Any special instructions or notes
|
||||
- payment_term_id: Payment terms (e.g., "Net 30", "2% 10 Net 30")
|
||||
RESPONSE FORMAT:
|
||||
Your response MUST ONLY be a valid JSON object with the following structure:
|
||||
|
||||
2. Suggest products or services that would meet the customer's needs.
|
||||
|
||||
IMPORTANT: Your response MUST be in valid JSON format as shown below. Do not include any explanatory text outside the JSON structure.
|
||||
|
||||
```json
|
||||
{{
|
||||
"sale_order_fields": {{
|
||||
"client_order_ref": "Customer PO number",
|
||||
"date_order": "YYYY-MM-DD",
|
||||
"commitment_date": "YYYY-MM-DD",
|
||||
"note": "Special instructions",
|
||||
"payment_term_id": "Payment terms"
|
||||
}},
|
||||
"products": [
|
||||
{{ "name": "Product Name", "quantity": 2, "description": "Product description" }},
|
||||
{{ "name": "Another Product", "quantity": 1, "description": "Another description" }}
|
||||
]
|
||||
"client_order_ref": "Customer PO number if mentioned",
|
||||
"date_order": "YYYY-MM-DD format if a specific order date is mentioned",
|
||||
"commitment_date": "YYYY-MM-DD format if a delivery date is mentioned",
|
||||
"note": "Any special instructions or notes for the order",
|
||||
"order_line": [
|
||||
[0, 0, {{
|
||||
"product_id": NUMERIC_ID_FROM_TOOL_CALL, // Must be an actual ID returned from a tool call
|
||||
"product_uom_qty": QUANTITY,
|
||||
"price_unit": PRICE
|
||||
}}],
|
||||
[0, 0, {{
|
||||
"display_type": "line_note",
|
||||
"name": "Unmatched product: Product description",
|
||||
"product_uom_qty": 0.0
|
||||
}}]
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
Only include fields and products that are clearly identified from the provided information.
|
||||
If you're not sure about a field or product, leave it blank or don't include it.
|
||||
If you cannot identify any products, return an empty products array but still include any sale_order_fields you can identify.
|
||||
|
||||
Remember: Your entire response must be valid JSON wrapped in code blocks. No other text.
|
||||
CRITICAL RULES:
|
||||
1. You MUST use the provided tools for EVERY product mentioned - DO NOT SKIP THIS STEP
|
||||
2. product_id MUST be a numeric ID returned by a tool call, NEVER make up IDs
|
||||
3. For products not found in the database, use the display_type: 'line_note' format
|
||||
4. Include as much detail as possible for unmatched products
|
||||
5. Return ONLY valid JSON with no text before or after
|
||||
6. DO NOT explain what you're doing or respond conversationally
|
||||
7. DO NOT say you can't create a sales order - your job is ONLY to return the JSON data
|
||||
IMPORTANT: You must invoke the tools directly using function calling, not just output text that looks like a tool call. Use the provided tools via function calling for _ai_find_product_id_by_code and _ai_find_product_id_by_name.
|
||||
"""
|
||||
|
||||
|
||||
# Call the AI with tools
|
||||
try:
|
||||
# Get the OpenWebUI client
|
||||
client = self.env['openai.openwebui.client']
|
||||
_logger.info("Sending request to AI for sales order generation")
|
||||
# First, get the AI's analysis with tool calls to find product IDs
|
||||
response = ai_client.chat_with_tools(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a data extraction system with access to tools for finding product IDs in an Odoo database. YOU MUST USE THE TOOLS PROVIDED TO ACCURATELY MATCH PRODUCTS PROVIDED TO THE DATABASE. Your ONLY job is to extract product information and return a structured JSON object. DO NOT engage in conversation or explain what you can or cannot do. ONLY return the requested JSON data structure."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
],
|
||||
tools=["_ai_find_product_id_by_name", "_ai_find_product_id_by_code"],
|
||||
tool_params={},
|
||||
max_tool_calls=25,
|
||||
files=attachments_list
|
||||
)
|
||||
_logger.info(f"Received AI response for ticket {self.id}")
|
||||
|
||||
# Create the messages for the AI
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
# Call the OpenWebUI API
|
||||
_logger.info("Calling OpenWebUI API for ticket %s", self.id)
|
||||
response = client.chat_completion(messages)
|
||||
|
||||
if response:
|
||||
_logger.info("Received AI response for ticket %s: %s", self.id, response[:100])
|
||||
|
||||
# Store the AI-generated products
|
||||
self.ai_generated_products = response
|
||||
|
||||
return True
|
||||
else:
|
||||
_logger.error("Empty response from OpenWebUI API for ticket %s", self.id)
|
||||
return False
|
||||
except Exception as e:
|
||||
_logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, str(e))
|
||||
_logger.error(f"Error in AI request: {e}")
|
||||
import traceback
|
||||
_logger.error("Traceback: %s", traceback.format_exc())
|
||||
return False
|
||||
|
||||
# Note: This method is kept for compatibility but is no longer used
|
||||
# The _ai_convert_to_sale_order method now creates the sale order directly
|
||||
def _generate_ai_so_values(self):
|
||||
"""Generate sale order values with AI-suggested products"""
|
||||
# Start with the base SO values from the parent method
|
||||
return self._generate_so_values()
|
||||
|
||||
def _parse_ai_product_suggestions(self):
|
||||
"""Parse the AI-generated product suggestions into sale order lines and fields"""
|
||||
result = {
|
||||
'order_lines': [],
|
||||
'sale_order_fields': {}
|
||||
_logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
"order_line": [],
|
||||
"note": f"AI Error: {str(e)}"
|
||||
}
|
||||
|
||||
# Process the AI response
|
||||
_logger.debug(f"AI response type: {type(response)}")
|
||||
|
||||
# The response from chat_with_tools should contain the content directly
|
||||
# If it's a dictionary, use it directly
|
||||
if isinstance(response, dict):
|
||||
_logger.info("AI returned dictionary response, using directly")
|
||||
return response
|
||||
|
||||
# Handle string responses (extract JSON if possible)
|
||||
if isinstance(response, str):
|
||||
_logger.info("AI returned text response, attempting to extract JSON")
|
||||
try:
|
||||
# Look for JSON pattern in the text
|
||||
json_pattern = r'```(?:json)?\s*({[\s\S]*?})\s*```'
|
||||
json_matches = re.findall(json_pattern, response)
|
||||
|
||||
if json_matches:
|
||||
response_data = json.loads(json_matches[0])
|
||||
_logger.info("Successfully extracted JSON from text response")
|
||||
return response_data
|
||||
elif response.strip().startswith('{') and response.strip().endswith('}'):
|
||||
response_data = json.loads(response.strip())
|
||||
_logger.info("Successfully parsed direct JSON from text response")
|
||||
return response_data
|
||||
else:
|
||||
_logger.error("Could not extract JSON from text response")
|
||||
return {
|
||||
"order_line": [],
|
||||
"note": f"AI returned invalid format: {response[:200]}..."
|
||||
}
|
||||
except Exception as parse_error:
|
||||
_logger.error(f"Failed to extract JSON from text response: {parse_error}")
|
||||
return {
|
||||
"order_line": [],
|
||||
"note": f"AI parsing error: {str(parse_error)}"
|
||||
}
|
||||
|
||||
# If we get here, the response is in an unexpected format
|
||||
_logger.error(f"Unexpected response format: {type(response)}")
|
||||
return {
|
||||
"order_line": [],
|
||||
"note": f"AI returned unexpected format: {type(response)}"
|
||||
}
|
||||
|
||||
if not self.ai_generated_products:
|
||||
_logger.warning("No AI generated products found for ticket %s", self.id)
|
||||
return result['order_lines']
|
||||
def _prepare_ai_prompt_data(self):
|
||||
"""
|
||||
Extract and prepare all relevant data from the helpdesk ticket for AI analysis.
|
||||
This includes ticket description, chatter messages, and attachment contents.
|
||||
|
||||
# Log the AI response for debugging
|
||||
_logger.info("Parsing AI product suggestions for ticket %s: %s", self.id, self.ai_generated_products[:300])
|
||||
Returns:
|
||||
dict: Dictionary containing ticket data for AI analysis
|
||||
"""
|
||||
self.ensure_one()
|
||||
_logger.info(f"Preparing AI prompt data for ticket {self.id}")
|
||||
|
||||
# First, try to extract JSON from the response using multiple patterns
|
||||
# Pattern 1: Standard code block with json tag
|
||||
json_pattern1 = r'```(?:json)?\s*({[\s\S]*?})\s*```'
|
||||
# Pattern 2: Just find any JSON-like structure with sale_order_fields or products
|
||||
json_pattern2 = r'({[\s\S]*?"(?:sale_order_fields|products)"[\s\S]*?})'
|
||||
# Pattern 3: Find any JSON-like structure (most permissive)
|
||||
json_pattern3 = r'({\s*"[^"]+"\s*:.*})' # Any JSON object with at least one key
|
||||
result = {
|
||||
'ticket_description': '',
|
||||
'ticket_messages': '',
|
||||
'attachments_info': '',
|
||||
'attachment_contents': ''
|
||||
}
|
||||
|
||||
json_matches = re.findall(json_pattern1, self.ai_generated_products)
|
||||
# Get ticket description
|
||||
if self.description:
|
||||
result['ticket_description'] = self.description
|
||||
|
||||
if not json_matches:
|
||||
_logger.info("No JSON found with pattern 1, trying pattern 2")
|
||||
json_matches = re.findall(json_pattern2, self.ai_generated_products)
|
||||
# Get chatter messages
|
||||
messages = []
|
||||
if self.message_ids:
|
||||
for message in self.message_ids:
|
||||
if message.body and not message.is_internal:
|
||||
# Skip system messages and focus on actual conversation
|
||||
if not message.author_id or message.author_id.name != 'OdooBot':
|
||||
# Format: [Author] on [Date]: [Message]
|
||||
author = message.author_id.name if message.author_id else 'System'
|
||||
date = message.date.strftime('%Y-%m-%d %H:%M') if message.date else ''
|
||||
# Clean HTML from message body
|
||||
body = re.sub(r'<[^>]+>', ' ', message.body)
|
||||
messages.append(f"[{author}] on {date}: {body}")
|
||||
|
||||
result['ticket_messages'] = '\n\n'.join(messages)
|
||||
|
||||
# Get attachments
|
||||
attachments = self.env['ir.attachment'].search([('res_id', '=', self.id), ('res_model', '=', self._name)])
|
||||
attachment_infos = []
|
||||
attachment_contents = []
|
||||
|
||||
for attachment in attachments:
|
||||
# Add attachment metadata
|
||||
attachment_infos.append(f"File: {attachment.name} ({attachment.mimetype}, {attachment.file_size} bytes)")
|
||||
|
||||
if not json_matches:
|
||||
_logger.info("No JSON found with pattern 2, trying pattern 3")
|
||||
json_matches = re.findall(json_pattern3, self.ai_generated_products)
|
||||
|
||||
if json_matches:
|
||||
# Try to parse the JSON
|
||||
try:
|
||||
# Clean up the JSON string before parsing
|
||||
json_str = json_matches[0]
|
||||
# Remove any trailing commas before closing brackets (common JSON error)
|
||||
json_str = re.sub(r',\s*([\]\}])', r'\1', json_str)
|
||||
|
||||
json_data = json.loads(json_str)
|
||||
_logger.info(f"Successfully parsed JSON data: {json_data}")
|
||||
|
||||
# Extract sale order fields
|
||||
if 'sale_order_fields' in json_data:
|
||||
result['sale_order_fields'] = json_data['sale_order_fields']
|
||||
_logger.info(f"Extracted sale order fields: {result['sale_order_fields']}")
|
||||
# Direct fields at root level (fallback)
|
||||
elif any(key in json_data for key in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']):
|
||||
so_fields = {}
|
||||
for field in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']:
|
||||
if field in json_data:
|
||||
so_fields[field] = json_data[field]
|
||||
result['sale_order_fields'] = so_fields
|
||||
_logger.info(f"Extracted sale order fields from root level: {result['sale_order_fields']}")
|
||||
|
||||
# Extract products - check multiple possible keys
|
||||
product_key = None
|
||||
for key in ['products', 'product_suggestions', 'order_lines', 'items']:
|
||||
if key in json_data and isinstance(json_data[key], list):
|
||||
product_key = key
|
||||
break
|
||||
|
||||
if product_key:
|
||||
for product in json_data[product_key]:
|
||||
if not isinstance(product, dict):
|
||||
continue
|
||||
|
||||
product_name = product.get('name')
|
||||
if not product_name:
|
||||
continue
|
||||
|
||||
quantity = product.get('quantity', 1.0)
|
||||
try:
|
||||
quantity = float(quantity)
|
||||
except (ValueError, TypeError):
|
||||
quantity = 1.0
|
||||
|
||||
description = product.get('description', '')
|
||||
|
||||
_logger.info(f"Processing product from JSON: {product_name}, qty={quantity}, desc={description}")
|
||||
|
||||
order_line = self._create_product_order_line(product_name, quantity, description)
|
||||
if order_line:
|
||||
result['order_lines'].append(order_line)
|
||||
|
||||
_logger.info(f"Parsed {len(result['order_lines'])} order lines from JSON")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
_logger.error(f"Failed to parse JSON: {e}")
|
||||
|
||||
# Try to extract just the sale order fields using regex as a last resort
|
||||
# Extract content from PDFs
|
||||
if attachment.mimetype == 'application/pdf':
|
||||
try:
|
||||
# Look for client_order_ref pattern
|
||||
po_pattern = r'(?:client_order_ref|PO number|purchase order)[\s"]*[:=]\s*["]*([^"\n,}]+)'
|
||||
po_match = re.search(po_pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if po_match:
|
||||
result['sale_order_fields']['client_order_ref'] = po_match.group(1).strip()
|
||||
|
||||
# Look for dates
|
||||
date_pattern = r'(?:date_order|order date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
|
||||
date_match = re.search(date_pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if date_match:
|
||||
result['sale_order_fields']['date_order'] = date_match.group(1)
|
||||
|
||||
# Look for commitment date
|
||||
commit_pattern = r'(?:commitment_date|delivery date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
|
||||
commit_match = re.search(commit_pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if commit_match:
|
||||
result['sale_order_fields']['commitment_date'] = commit_match.group(1)
|
||||
|
||||
# Look for payment terms
|
||||
payment_pattern = r'(?:payment_term_id|payment terms)[\s"]*[:=]\s*["]*([^"\n,}]+)'
|
||||
payment_match = re.search(payment_pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if payment_match:
|
||||
result['sale_order_fields']['payment_term_id'] = payment_match.group(1).strip()
|
||||
|
||||
if result['sale_order_fields']:
|
||||
_logger.info(f"Extracted sale order fields using regex: {result['sale_order_fields']}")
|
||||
except Exception as regex_error:
|
||||
_logger.error(f"Error in regex extraction fallback: {regex_error}")
|
||||
|
||||
# If JSON parsing failed, fall back to the old parsing methods
|
||||
_logger.info("Falling back to legacy parsing methods")
|
||||
|
||||
# Try to extract reference number using regex before falling back to line-by-line parsing
|
||||
ref_patterns = [
|
||||
r'(?:reference|ticket|po|purchase order)[\s\-]*(?:number|#)?[\s\-:]*([\d\-]+)',
|
||||
r'(?:client_order_ref|order ref)[\s"]*[:=]\s*["]*([^"\n,}]+)'
|
||||
]
|
||||
|
||||
for pattern in ref_patterns:
|
||||
ref_match = re.search(pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if ref_match:
|
||||
ref_number = ref_match.group(1).strip()
|
||||
_logger.info(f"Found reference number using regex: {ref_number}")
|
||||
result['sale_order_fields']['client_order_ref'] = ref_number
|
||||
break
|
||||
order_lines = []
|
||||
|
||||
# Try to parse the AI response in different formats
|
||||
# First, look for a table format with | separators
|
||||
table_pattern = r"([^|\n]+)\s*\|\s*(\d*\.?\d*)\s*\|\s*([^|\n]*)"
|
||||
table_matches = re.findall(table_pattern, self.ai_generated_products)
|
||||
|
||||
if table_matches:
|
||||
# Process table format
|
||||
_logger.info(f"Found table format with {len(table_matches)} matches")
|
||||
for match in table_matches:
|
||||
product_name = match[0].strip()
|
||||
if not product_name or product_name.lower() in ['product/service name', 'product', 'service', 'item']:
|
||||
continue
|
||||
# Create temporary file
|
||||
temp_path = f"/tmp/{attachment.name}"
|
||||
shutil.copy(attachment._full_path(attachment.store_fname), temp_path)
|
||||
|
||||
# Parse quantity
|
||||
quantity = 1.0
|
||||
if match[1].strip():
|
||||
try:
|
||||
quantity = float(match[1].strip())
|
||||
except ValueError:
|
||||
quantity = 1.0
|
||||
|
||||
# Get description
|
||||
description = match[2].strip() if match[2].strip() else product_name
|
||||
|
||||
# Add the order line
|
||||
order_line = self._create_product_order_line(product_name, quantity, description)
|
||||
if order_line:
|
||||
order_lines.append(order_line)
|
||||
else:
|
||||
# Try to parse line by line for products and quantities
|
||||
# Look for patterns like "2x Product Name" or "Product Name (qty: 3)" or "Product Name - 4 units"
|
||||
lines = self.ai_generated_products.strip().split('\n')
|
||||
_logger.info(f"Parsing line by line, found {len(lines)} lines")
|
||||
|
||||
# Skip header lines and empty lines
|
||||
processed_lines = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
# Skip empty lines, headers, and other non-product lines
|
||||
if (not line or
|
||||
line.startswith('#') or
|
||||
line.lower().startswith('product') or
|
||||
line.lower() == 'format your response as' or
|
||||
line.lower() == 'for example:'):
|
||||
continue
|
||||
|
||||
# Remove bullet points and other common prefixes
|
||||
line = re.sub(r'^[-*\u2022]\s*', '', line)
|
||||
processed_lines.append(line)
|
||||
|
||||
for line in processed_lines:
|
||||
_logger.info(f"Processing line: {line}")
|
||||
|
||||
# Try to extract quantity, product name, part number, and description
|
||||
# Format examples:
|
||||
# - 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency
|
||||
# - 1x Preventive Maintenance Service: Annual service package
|
||||
# - 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI
|
||||
|
||||
# Pattern for the format specified in the prompt template
|
||||
detailed_pattern = r"(\d+)x\s+([^:]+?)(?:\s+([A-Z0-9][A-Z0-9-]+))?\s*:?\s*(.*)"
|
||||
match = re.search(detailed_pattern, line, re.IGNORECASE)
|
||||
|
||||
if match:
|
||||
quantity = float(match.group(1))
|
||||
product_name = match.group(2).strip()
|
||||
part_number = match.group(3) if match.group(3) else ''
|
||||
specs = match.group(4).strip() if match.group(4) else ''
|
||||
# For PDF extraction, we'll just note the PDF file is present
|
||||
# The actual extraction will be handled by the AI service which has built-in PDF processing
|
||||
attachment_contents.append(f"PDF file: {attachment.name} (will be processed by AI)")
|
||||
|
||||
# Combine part number with product name if available
|
||||
if part_number:
|
||||
full_product_name = f"{product_name} {part_number}"
|
||||
else:
|
||||
full_product_name = product_name
|
||||
|
||||
# Use specifications as description if available
|
||||
description = specs if specs else product_name
|
||||
|
||||
_logger.info(f"Matched detailed pattern: qty={quantity}, product={full_product_name}, desc={description}")
|
||||
|
||||
order_line = self._create_product_order_line(full_product_name, quantity, description)
|
||||
if order_line:
|
||||
order_lines.append(order_line)
|
||||
continue
|
||||
|
||||
# Try other common patterns if the detailed pattern didn't match
|
||||
qty_patterns = [
|
||||
r"(\d+(?:\.\d+)?)\s*x\s*([^\d\n]+)", # "2x Product Name" or "2.5x Product Name"
|
||||
r"([^\d\n]+)\s*\(\s*qty\s*:\s*(\d+(?:\.\d+)?)\s*\)", # "Product Name (qty: 3)"
|
||||
r"([^\d\n]+)\s*-\s*(\d+(?:\.\d+)?)\s*units?", # "Product Name - 4 units"
|
||||
r"([^\d\n]+)\s*:\s*(\d+(?:\.\d+)?)", # "Product Name: 2"
|
||||
r"quantity\s*:\s*(\d+(?:\.\d+)?)\s*,?\s*([^,]+)", # "Quantity: 2, Product Name"
|
||||
]
|
||||
|
||||
product_name = None
|
||||
quantity = 1.0
|
||||
description = ""
|
||||
|
||||
for pattern in qty_patterns:
|
||||
match = re.search(pattern, line, re.IGNORECASE)
|
||||
if match:
|
||||
if pattern == qty_patterns[0]: # "2x Product Name"
|
||||
try:
|
||||
quantity = float(match.group(1))
|
||||
product_name = match.group(2).strip()
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
else: # Other patterns
|
||||
try:
|
||||
product_name = match.group(1).strip()
|
||||
quantity = float(match.group(2))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# Try to extract description after the product name
|
||||
desc_match = re.search(r"[^:]+:(.+)$", line)
|
||||
if desc_match:
|
||||
description = desc_match.group(1).strip()
|
||||
|
||||
_logger.info(f"Matched pattern {pattern}: qty={quantity}, product={product_name}, desc={description}")
|
||||
break
|
||||
|
||||
# If no pattern matched, use the whole line as product name
|
||||
if not product_name:
|
||||
# Check if there's a colon that might separate product name from description
|
||||
if ':' in line:
|
||||
parts = line.split(':', 1)
|
||||
product_name = parts[0].strip()
|
||||
description = parts[1].strip() if len(parts) > 1 else ''
|
||||
else:
|
||||
product_name = line
|
||||
description = ''
|
||||
|
||||
_logger.info(f"No pattern match, using line as product: {product_name}, desc={description}")
|
||||
|
||||
# Add the order line
|
||||
order_line = self._create_product_order_line(product_name, quantity, description)
|
||||
if order_line:
|
||||
order_lines.append(order_line)
|
||||
# Note: If PDF text extraction is needed directly in Odoo, consider adding:
|
||||
# - A dependency on pdf2text or PyPDF2 in the module manifest
|
||||
# - Implementing the extraction logic here
|
||||
except Exception as e:
|
||||
_logger.error(f"Error extracting content from PDF {attachment.name}: {e}")
|
||||
|
||||
result['attachments_info'] = '\n'.join(attachment_infos)
|
||||
result['attachment_contents'] = '\n\n'.join(attachment_contents)
|
||||
|
||||
result['order_lines'] = order_lines
|
||||
_logger.info(f"Parsed {len(order_lines)} order lines from AI suggestions")
|
||||
return result
|
||||
|
||||
def _create_product_order_line(self, product_name, quantity, description=""):
|
||||
"""Create a sale order line for a product"""
|
||||
if not product_name:
|
||||
return False
|
||||
|
||||
# Search for matching product - try exact match first
|
||||
product = self.env['product.product'].search([
|
||||
('name', '=', product_name),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1)
|
||||
|
||||
# If no exact match, try partial match
|
||||
if not product:
|
||||
product = self.env['product.product'].search([
|
||||
('name', 'ilike', product_name),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1)
|
||||
|
||||
# If still no product found, try matching by default_code (SKU/part number)
|
||||
if not product and any(c.isdigit() for c in product_name): # Check if product name contains numbers (likely a part number)
|
||||
# Extract potential part numbers
|
||||
part_numbers = re.findall(r'[A-Z0-9][A-Z0-9-]+', product_name)
|
||||
for part in part_numbers:
|
||||
product = self.env['product.product'].search([
|
||||
('default_code', '=', part),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1)
|
||||
if product:
|
||||
break
|
||||
|
||||
# If no product found, log it and return False
|
||||
if not product:
|
||||
_logger.info(f"No matching product found for: {product_name}")
|
||||
return False
|
||||
|
||||
def _prepare_order_line_values(self, product, quantity, description=""):
|
||||
"""Prepare values for creating a sale order line"""
|
||||
# Create order line with price information
|
||||
line_values = {
|
||||
'product_id': product.id,
|
||||
|
|
@ -689,4 +370,35 @@ class HelpdeskTicket(models.Model):
|
|||
|
||||
# We intentionally don't set price_unit here to let Odoo's standard mechanisms handle it
|
||||
|
||||
return (0, 0, line_values)
|
||||
return line_values
|
||||
|
||||
def _ai_find_product_id_by_name(self, product_name: str) -> int | None:
|
||||
"""Use AI to find a product by name
|
||||
|
||||
Args:
|
||||
product_name: The name of the product to find
|
||||
|
||||
Returns:
|
||||
The ID of the product if found, or None if not found
|
||||
"""
|
||||
|
||||
return self.env['product.product'].search([
|
||||
('name', 'ilike', product_name),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1).id
|
||||
|
||||
def _ai_find_product_id_by_code(self, product_reference: str) -> int | None:
|
||||
"""Use AI to find a product by code
|
||||
|
||||
Args:
|
||||
product_reference: The code of the product to find
|
||||
|
||||
Returns:
|
||||
The ID of the product if found, or None if not found
|
||||
"""
|
||||
|
||||
return self.env['product.product'].search([
|
||||
('default_code', 'ilike', product_reference),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1).id
|
||||
|
||||
1629
helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak
Normal file
1629
helpdesk_sale_order_ai/models/helpdesk_ticket.py.bak
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,23 +7,12 @@ _logger = logging.getLogger(__name__)
|
|||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# We no longer need to define the fields here as they are now defined in the openai_connector module
|
||||
# This ensures that the settings are always in sync between the two modules
|
||||
|
||||
# Override the model field to use our selection method
|
||||
openai_model = fields.Selection(
|
||||
selection='_get_openwebui_models',
|
||||
string='AI Model',
|
||||
help='Model to use for AI API calls',
|
||||
default='anthropic.claude-3-7-sonnet-latest',
|
||||
config_parameter='openai.model',
|
||||
)
|
||||
# Inherit the fields from openwebui_base module
|
||||
# These fields are already defined in the openwebui_base module
|
||||
|
||||
# Add a field to select the prompt template directly
|
||||
# In Odoo 18, we need to ensure this field is properly defined
|
||||
helpdesk_ai_prompt_template_id = fields.Many2one(
|
||||
'openwebui.prompt.template',
|
||||
domain="[('template_type', '=', 'helpdesk')]",
|
||||
string='Default AI Prompt Template',
|
||||
help='Default template for the prompt sent to the AI.',
|
||||
ondelete='set null', # This helps avoid constraint errors
|
||||
|
|
@ -55,37 +44,6 @@ class ResConfigSettings(models.TransientModel):
|
|||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
if self.helpdesk_ai_prompt_template_id:
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.helpdesk_ai_prompt_template_id.id))
|
||||
else:
|
||||
# If no template is selected, ensure there's a default one
|
||||
self._set_default_values()
|
||||
|
||||
@api.model
|
||||
def _get_openwebui_models(self):
|
||||
"""Get available models from OpenWebUI API"""
|
||||
try:
|
||||
client = self.env['openwebui.client']
|
||||
models = client.get_available_models()
|
||||
return models
|
||||
except Exception as e:
|
||||
_logger.error(f"Error fetching OpenWebUI models: {e}")
|
||||
# Return default model on error
|
||||
default_model = 'anthropic.claude-3-7-sonnet-latest'
|
||||
return [(default_model, default_model)]
|
||||
|
||||
@api.model
|
||||
def _set_default_values(self):
|
||||
"""Set default values for configuration parameters"""
|
||||
# Ensure there's a default template
|
||||
template_model = self.env['openwebui.prompt.template']
|
||||
template_model._ensure_default_template()
|
||||
default_template = template_model.get_default_template()
|
||||
|
||||
# Set the default template ID in config parameters if not set
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
|
||||
if not default_template_id and default_template:
|
||||
# Store as string to ensure compatibility with the OpenAI connector module
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
|
||||
@api.model
|
||||
def get_prompt_template(self):
|
||||
|
|
@ -96,10 +54,10 @@ class ResConfigSettings(models.TransientModel):
|
|||
if template_id:
|
||||
try:
|
||||
# Make sure the template model exists first
|
||||
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
self.env['ai.openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
# The template_id is already an integer in the database now
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
template = self.env['openwebui.prompt.template'].browse(template_id_int)
|
||||
template = self.env['ai.openwebui.prompt.template'].browse(template_id_int)
|
||||
if template.exists():
|
||||
return template.content
|
||||
except (ValueError, TypeError) as e:
|
||||
|
|
@ -107,5 +65,5 @@ class ResConfigSettings(models.TransientModel):
|
|||
pass
|
||||
|
||||
# Fallback to default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
default_template = self.env['ai.openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
return default_template.content if default_template else ""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
|
@ -27,3 +30,56 @@ class SaleOrder(models.Model):
|
|||
copy=False,
|
||||
help='Helpdesk ticket from which this sale order was created'
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to delete missing products message when sale order is confirmed"""
|
||||
# Call the original method first
|
||||
result = super(SaleOrder, self).action_confirm()
|
||||
|
||||
# Delete missing products message if exists
|
||||
if self.has_missing_products:
|
||||
self._delete_missing_products_message()
|
||||
|
||||
# Reset missing products flags
|
||||
self.write({
|
||||
'missing_product_count': 0,
|
||||
'has_missing_products': False
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def _delete_missing_products_message(self):
|
||||
"""Delete the missing products message from the chatter"""
|
||||
# Find the message with the unique identifier
|
||||
domain = [
|
||||
('res_id', '=', self.id),
|
||||
('model', '=', 'sale.order'),
|
||||
('body', 'ilike', '%<!-- MISSING_PRODUCTS_MESSAGE -->%'),
|
||||
]
|
||||
|
||||
messages = self.env['mail.message'].sudo().search(domain)
|
||||
|
||||
if messages:
|
||||
_logger.info(f"Found {len(messages)} missing products message(s) for sale order {self.id} with IDs: {messages.ids}")
|
||||
try:
|
||||
# Make sure we're actually deleting the message
|
||||
message_ids = messages.ids # Store IDs before deletion
|
||||
messages.sudo().unlink()
|
||||
_logger.info(f"Successfully deleted missing products message(s) with IDs: {message_ids} for sale order {self.id}")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error deleting missing products message: {e}")
|
||||
else:
|
||||
_logger.warning(f"No missing products message found for sale order {self.id} - check HTML comment marker")
|
||||
|
||||
# Debug: Try a broader search to see if there are any messages at all
|
||||
all_messages = self.env['mail.message'].sudo().search([
|
||||
('res_id', '=', self.id),
|
||||
('model', '=', 'sale.order'),
|
||||
], limit=5)
|
||||
|
||||
if all_messages:
|
||||
_logger.info(f"Found {len(all_messages)} recent messages for this sale order. First message body sample: {all_messages[0].body[:100] if all_messages[0].body else 'Empty'}...")
|
||||
else:
|
||||
_logger.info(f"No messages found at all for sale order {self.id}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_openwebui_prompt_template_helpdesk_user,openwebui.prompt.template.helpdesk.user,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
|
||||
access_openwebui_prompt_template_helpdesk_manager,openwebui.prompt.template.helpdesk.manager,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1
|
||||
access_ai_openwebui_prompt_template_helpdesk_user,ai.openwebui.prompt.template.helpdesk.user,openwebui_base.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
|
||||
access_ai_openwebui_prompt_template_helpdesk_manager,ai.openwebui.prompt.template.helpdesk.manager,openwebui_base.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1
|
||||
|
|
|
|||
|
|
|
@ -6,40 +6,19 @@
|
|||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Prompt Template">
|
||||
<field name="template_type" invisible="1"/>
|
||||
<field name="template_type" invisible="0"/>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="set_as_default" type="object"
|
||||
class="oe_stat_button" icon="fa-star"
|
||||
invisible="is_default">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Set as Default</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="set_as_default" type="object"
|
||||
class="oe_stat_button" icon="fa-star" disabled="1"
|
||||
invisible="not is_default">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Default Template</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Template Name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="sequence" groups="base.group_no_one"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="is_default" invisible="1"/>
|
||||
<field name="is_default"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Template Content" name="template_content">
|
||||
<div class="text-muted mb-3">
|
||||
Use placeholders like {description}, {customer}, etc. to include ticket information in the prompt.
|
||||
</div>
|
||||
<field name="content" widget="text" class="oe_edit_only" style="min-height: 300px;"/>
|
||||
<page string="Template Content">
|
||||
<field name="content" nolabel="1" placeholder="Enter the prompt template content here..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
|
|
@ -53,11 +32,9 @@
|
|||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="template_type" invisible="1"/>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="template_type"/>
|
||||
<field name="name"/>
|
||||
<field name="is_default" string="Default"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
|
@ -68,10 +45,12 @@
|
|||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Prompt Templates">
|
||||
<field name="template_type" invisible="1"/>
|
||||
<field name="template_type"/>
|
||||
<field name="name"/>
|
||||
<filter string="Default" name="default" domain="[('is_default', '=', True)]"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<filter string="Helpdesk Templates" name="helpdesk" domain="[('template_type', '=', 'helpdesk')]"/>
|
||||
<filter string="General Templates" name="general" domain="[('template_type', '=', 'general')]"/>
|
||||
<filter string="Custom Templates" name="custom" domain="[('template_type', '=', 'custom')]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
|
@ -81,14 +60,15 @@
|
|||
<field name="name">AI Prompt Templates</field>
|
||||
<field name="res_model">openwebui.prompt.template</field>
|
||||
<field name="domain">[('template_type', '=', 'helpdesk')]</field>
|
||||
<field name="context">{'default_template_type': 'helpdesk'}</field>
|
||||
<field name="context">{"default_template_type": "helpdesk"}</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_helpdesk_ai_prompt_template_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new AI prompt template
|
||||
Create your first AI prompt template!
|
||||
</p>
|
||||
<p>
|
||||
Define templates for AI prompts used in helpdesk tickets.
|
||||
AI prompt templates are used to generate product suggestions from helpdesk tickets.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- We need to make the field available in the form without adding UI elements -->
|
||||
<!-- Add the hidden field to the base settings form -->
|
||||
<record id="res_config_settings_view_form_inherit_helpdesk_sale_order_ai" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.helpdesk.sale.order.ai</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- In Odoo 18, we add fields to the form directly -->
|
||||
<form position="inside">
|
||||
<!-- Hidden field that will be used by the settings logic -->
|
||||
<!-- Add the hidden field at the root level of the form -->
|
||||
<field name="company_id" position="after">
|
||||
<field name="helpdesk_ai_prompt_template_id" invisible="1"/>
|
||||
</form>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import openwebui_config_wizard
|
||||
1
openwebui_base/__init__.py
Normal file
1
openwebui_base/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
30
openwebui_base/__manifest__.py
Normal file
30
openwebui_base/__manifest__.py
Normal 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,
|
||||
}
|
||||
5
openwebui_base/models/__init__.py
Normal file
5
openwebui_base/models/__init__.py
Normal 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
|
||||
29
openwebui_base/models/openwebui_model.py
Normal file
29
openwebui_base/models/openwebui_model.py
Normal 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",
|
||||
)
|
||||
]
|
||||
85
openwebui_base/models/openwebui_prompt_template.py
Normal file
85
openwebui_base/models/openwebui_prompt_template.py
Normal 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)
|
||||
66
openwebui_base/models/openwebui_provider.py
Normal file
66
openwebui_base/models/openwebui_provider.py
Normal 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
|
||||
15
openwebui_base/models/res_company.py
Normal file
15
openwebui_base/models/res_company.py
Normal 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")],
|
||||
)
|
||||
44
openwebui_base/models/res_config_settings.py
Normal file
44
openwebui_base/models/res_config_settings.py
Normal 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))
|
||||
7
openwebui_base/security/ir.model.access.csv
Normal file
7
openwebui_base/security/ir.model.access.csv
Normal 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
|
||||
|
BIN
openwebui_base/static/description/icon.png
Normal file
BIN
openwebui_base/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
7
openwebui_base/static/description/icon.svg
Normal file
7
openwebui_base/static/description/icon.svg
Normal 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 |
1
openwebui_base/tests/__init__.py
Normal file
1
openwebui_base/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_openwebui
|
||||
52
openwebui_base/tests/test_openwebui.py
Normal file
52
openwebui_base/tests/test_openwebui.py
Normal 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)
|
||||
78
openwebui_base/views/openwebui_model_views.xml
Normal file
78
openwebui_base/views/openwebui_model_views.xml
Normal 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>
|
||||
99
openwebui_base/views/openwebui_provider_views.xml
Normal file
99
openwebui_base/views/openwebui_provider_views.xml
Normal 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>
|
||||
39
openwebui_base/views/res_config_settings.xml
Normal file
39
openwebui_base/views/res_config_settings.xml
Normal 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>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'OpenWebUI Connector',
|
||||
'version': '18.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Connect to OpenWebUI and other AI services',
|
||||
'description': """
|
||||
OpenWebUI Connector
|
||||
===============
|
||||
This module provides integration with OpenWebUI and other AI services.
|
||||
It allows other modules to use AI capabilities through a standardized interface.
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'maintainer': 'it@bemade.org',
|
||||
'depends': ['base'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/ai_prompt_template_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/ai_prompt_template_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
'external_dependencies': {
|
||||
'python': ['requests'],
|
||||
},
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Default Helpdesk AI Prompt Template -->
|
||||
<record id="default_helpdesk_prompt_template" model="openwebui.prompt.template">
|
||||
<field name="name">Default Helpdesk Template</field>
|
||||
<field name="template_type">helpdesk</field>
|
||||
<field name="is_default" eval="True"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="content">You are a helpful AI assistant for Bemade. Your task is to analyze a helpdesk ticket and suggest products or services that should be included in a sales order.
|
||||
|
||||
TICKET INFORMATION:
|
||||
Customer: {customer}
|
||||
Subject: {subject}
|
||||
Description: {description}
|
||||
|
||||
Based on the ticket information above, please suggest products or services that should be included in a sales order. Format your response as a list of products with quantities. If possible, match to existing products in our catalog.
|
||||
|
||||
For each product suggestion, include:
|
||||
1. Product name
|
||||
2. Quantity
|
||||
3. Brief justification for including this item
|
||||
|
||||
Format your response as follows:
|
||||
```
|
||||
PRODUCT SUGGESTIONS:
|
||||
- 2x Product Name: Justification
|
||||
- 1x Another Product: Justification
|
||||
```
|
||||
|
||||
If you need more information to make accurate suggestions, please indicate what information is missing.</field>
|
||||
</record>
|
||||
|
||||
<!-- Default General AI Prompt Template -->
|
||||
<record id="default_general_prompt_template" model="openwebui.prompt.template">
|
||||
<field name="name">Default General Template</field>
|
||||
<field name="template_type">general</field>
|
||||
<field name="is_default" eval="True"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="content">You are a helpful AI assistant for Bemade. Please analyze the following content and provide a concise and helpful response:
|
||||
|
||||
{content}
|
||||
|
||||
Please provide a clear and professional response.</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is intentionally left empty to make the directory a proper Python package
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run after the module update
|
||||
# It will ensure the system parameters are properly set up
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Ensure system parameters are properly set up after migration
|
||||
"""
|
||||
# Set default values for system parameters
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('openwebui.base_url', 'https://ai.bemade.org/api', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('openwebui.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
|
||||
'Post-migration: Set default system parameters',
|
||||
'/addons/openwebui_connector/migrations/1.0/post-migration.py', 30, 'migrate')
|
||||
""")
|
||||
|
||||
# Check if helpdesk_sale_order_ai module is installed
|
||||
cr.execute("""
|
||||
SELECT id FROM ir_module_module
|
||||
WHERE name = 'helpdesk_sale_order_ai'
|
||||
AND state = 'installed'
|
||||
""")
|
||||
|
||||
if cr.fetchone():
|
||||
_logger.info("helpdesk_sale_order_ai module is installed, ensuring parameters are set")
|
||||
|
||||
# Ensure the helpdesk AI parameter exists
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('helpdesk_sale_order_ai.use_ai_sale_orders', 'False', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
|
||||
'Post-migration: Set default helpdesk AI parameters',
|
||||
'/addons/openwebui_connector/migrations/1.0/post-migration.py', 50, 'migrate')
|
||||
""")
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run before the module update
|
||||
# It will handle database constraints and column issues for Odoo 18 compatibility
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Handle database constraints and column issues for Odoo 18 compatibility
|
||||
"""
|
||||
# 1. Check and drop any foreign key constraints on helpdesk_ai_prompt_template_id
|
||||
cr.execute("""
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_name = 'res_config_settings'
|
||||
AND ccu.column_name = 'helpdesk_ai_prompt_template_id'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
""")
|
||||
|
||||
constraints = cr.fetchall()
|
||||
for constraint in constraints:
|
||||
constraint_name = constraint[0]
|
||||
# Drop the constraint
|
||||
cr.execute(f"""
|
||||
ALTER TABLE res_config_settings
|
||||
DROP CONSTRAINT IF EXISTS {constraint_name}
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
|
||||
'Migration: Dropped foreign key constraint: %s',
|
||||
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 30, 'migrate')
|
||||
""", (constraint_name,))
|
||||
|
||||
# 2. Check if the column exists and drop it if needed
|
||||
cr.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'res_config_settings'
|
||||
AND column_name = 'helpdesk_ai_prompt_template_id'
|
||||
""")
|
||||
|
||||
if cr.fetchone():
|
||||
# We need to drop the column to avoid conflicts with the new field
|
||||
try:
|
||||
cr.execute("""
|
||||
ALTER TABLE res_config_settings
|
||||
DROP COLUMN IF EXISTS helpdesk_ai_prompt_template_id
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
|
||||
'Migration: Dropped column helpdesk_ai_prompt_template_id',
|
||||
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 50, 'migrate')
|
||||
""")
|
||||
except Exception as e:
|
||||
# Log the error
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'error',
|
||||
'Migration error: %s',
|
||||
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 60, 'migrate')
|
||||
""", (str(e),))
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run after the module update
|
||||
# It will migrate data from openai_prompt_template to openwebui_prompt_template
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Migrate data from openai_prompt_template to openwebui_prompt_template
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
|
||||
_logger.info("Starting migration of prompt templates from openai to openwebui")
|
||||
|
||||
# Check if the old table exists
|
||||
cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
|
||||
if not cr.fetchone()[0]:
|
||||
_logger.info("No openai_prompt_template table found, skipping migration")
|
||||
return
|
||||
|
||||
# Check if there's data to migrate
|
||||
cr.execute("SELECT COUNT(*) FROM openai_prompt_template")
|
||||
count = cr.fetchone()[0]
|
||||
_logger.info(f"Found {count} records to migrate from openai_prompt_template")
|
||||
|
||||
if count > 0:
|
||||
# Copy data from old table to new table
|
||||
cr.execute("""
|
||||
INSERT INTO openwebui_prompt_template (
|
||||
id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
|
||||
)
|
||||
SELECT
|
||||
id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
|
||||
FROM
|
||||
openai_prompt_template
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
content = EXCLUDED.content,
|
||||
is_default = EXCLUDED.is_default,
|
||||
module = EXCLUDED.module,
|
||||
write_uid = EXCLUDED.write_uid,
|
||||
write_date = EXCLUDED.write_date
|
||||
""")
|
||||
|
||||
# Update sequence if needed
|
||||
cr.execute("""
|
||||
SELECT MAX(id) FROM openwebui_prompt_template
|
||||
""")
|
||||
max_id = cr.fetchone()[0] or 0
|
||||
if max_id > 0:
|
||||
cr.execute(f"""
|
||||
SELECT setval('openwebui_prompt_template_id_seq', {max_id})
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
_logger.info(f"Successfully migrated {count} records from openai_prompt_template to openwebui_prompt_template")
|
||||
|
||||
# Update any references in res_config_settings
|
||||
cr.execute("""
|
||||
UPDATE ir_config_parameter
|
||||
SET key = REPLACE(key, 'openai.', 'openwebui.')
|
||||
WHERE key LIKE 'openai.%'
|
||||
""")
|
||||
|
||||
# Update any references to prompt template IDs in system parameters
|
||||
cr.execute("""
|
||||
UPDATE ir_config_parameter
|
||||
SET key = REPLACE(key, 'openai_prompt_template_id', 'openwebui_prompt_template_id')
|
||||
WHERE key LIKE '%openai_prompt_template_id%'
|
||||
""")
|
||||
|
||||
_logger.info("Updated system parameters to use openwebui prefix")
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run before the module update
|
||||
# It will handle database constraints and prepare for the migration
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Handle database constraints and prepare for migration
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
|
||||
_logger.info("Starting pre-migration for openwebui_connector")
|
||||
|
||||
# 1. Drop foreign key constraints on ai_prompt_template_id to avoid conflicts
|
||||
cr.execute("""
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_name = 'res_config_settings'
|
||||
AND ccu.column_name = 'ai_prompt_template_id'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
""")
|
||||
|
||||
constraints = cr.fetchall()
|
||||
for constraint in constraints:
|
||||
constraint_name = constraint[0]
|
||||
# Drop the constraint
|
||||
cr.execute(f"""
|
||||
ALTER TABLE res_config_settings
|
||||
DROP CONSTRAINT IF EXISTS {constraint_name}
|
||||
""")
|
||||
_logger.info(f"Dropped foreign key constraint: {constraint_name}")
|
||||
|
||||
# 2. Update system parameters to use new naming
|
||||
cr.execute("""
|
||||
UPDATE ir_config_parameter
|
||||
SET key = REPLACE(key, 'openai.', 'openwebui.')
|
||||
WHERE key LIKE 'openai.%'
|
||||
""")
|
||||
|
||||
# 3. Check if the old openai_prompt_template table exists
|
||||
cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
|
||||
if cr.fetchone()[0]:
|
||||
# Create a backup of the data if needed
|
||||
cr.execute("""
|
||||
CREATE TABLE IF NOT EXISTS openai_prompt_template_backup AS
|
||||
SELECT * FROM openai_prompt_template
|
||||
""")
|
||||
_logger.info("Created backup of openai_prompt_template data")
|
||||
|
||||
_logger.info("Pre-migration completed successfully")
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is intentionally left empty to make the directory a proper Python package
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import openwebui_client
|
||||
from . import ai_prompt_template
|
||||
from . import res_config_settings
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class AIPromptTemplate(models.Model):
|
||||
_name = 'openwebui.prompt.template'
|
||||
_description = 'AI Prompt Template'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
content = fields.Text(
|
||||
string='Template Content',
|
||||
required=True,
|
||||
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.'
|
||||
)
|
||||
template_type = fields.Selection([
|
||||
('helpdesk', 'Helpdesk'),
|
||||
('general', 'General'),
|
||||
], string='Template Type', default='helpdesk', required=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
is_default = fields.Boolean(string='Default Template', default=False)
|
||||
|
||||
@api.model
|
||||
def _ensure_default_template(self, template_type='helpdesk'):
|
||||
"""Ensure that at least one default template exists for the given type"""
|
||||
_logger.info(f"Ensuring default template exists for type: {template_type}")
|
||||
try:
|
||||
default_template = self.search([('template_type', '=', template_type), ('is_default', '=', True)], limit=1)
|
||||
_logger.info(f"Found existing default template: {bool(default_template)}")
|
||||
|
||||
if not default_template:
|
||||
_logger.info("No default template found, creating one")
|
||||
# Create a default template
|
||||
default_content = self._get_default_template_content(template_type)
|
||||
default_name = f"Default {template_type.capitalize()} Template"
|
||||
|
||||
try:
|
||||
_logger.info(f"Creating default template with name: {default_name}")
|
||||
default_template = self.create({
|
||||
'name': default_name,
|
||||
'template_type': template_type,
|
||||
'content': default_content,
|
||||
'is_default': True,
|
||||
})
|
||||
_logger.info(f"Default template created with ID: {default_template.id}")
|
||||
|
||||
# Set the default template ID in system parameters
|
||||
if template_type == 'helpdesk':
|
||||
_logger.info("Setting default template ID in system parameters")
|
||||
param_name = 'helpdesk_sale_order_ai.default_prompt_template_id'
|
||||
param_value = str(default_template.id)
|
||||
_logger.info(f"Setting parameter {param_name} = {param_value}")
|
||||
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
param_name, param_value
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(f"Error creating default template: {e}", exc_info=True)
|
||||
_logger.error(f"Template data: name={default_name}, type={template_type}, content_length={len(default_content) if default_content else 0}")
|
||||
|
||||
return default_template
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error in _ensure_default_template: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _get_default_template_content(self, template_type):
|
||||
"""Get the default content for a template type"""
|
||||
if template_type == 'helpdesk':
|
||||
return """Based on the following helpdesk ticket information, create a complete sales order that accurately reflects the client's requirements.
|
||||
|
||||
Ticket Information: {description}
|
||||
Customer: {customer}
|
||||
Subject: {subject}
|
||||
|
||||
Analyze all information including ticket description, chatter messages, and attachments to create a comprehensive sales order that includes ALL items the client has requested.
|
||||
|
||||
Please provide a detailed list of products and services with exact quantities, specifications, and any relevant details mentioned by the client. Include part numbers when available.
|
||||
|
||||
Format your response as follows:
|
||||
- [Quantity]x [Product Name] [Part Number if available]: [Specifications/Details]
|
||||
|
||||
For example:
|
||||
- 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency as specified in technical document
|
||||
- 1x Preventive Maintenance Service: Annual service package including parts and labor
|
||||
- 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI max pressure
|
||||
|
||||
If the client has provided pricing expectations or budget constraints, include this information. If specific delivery timeframes are mentioned, note these as well.
|
||||
"""
|
||||
elif template_type == 'general':
|
||||
return """You are a helpful AI assistant. Please analyze the following content and provide a concise and helpful response:
|
||||
|
||||
{content}
|
||||
"""
|
||||
else:
|
||||
return """Please provide a prompt template for this type of content."""
|
||||
|
||||
def set_as_default(self):
|
||||
"""Set this template as the default for its type"""
|
||||
# First, unset any existing default templates of the same type
|
||||
other_defaults = self.search([
|
||||
('id', '!=', self.id),
|
||||
('template_type', '=', self.template_type),
|
||||
('is_default', '=', True)
|
||||
])
|
||||
other_defaults.write({'is_default': False})
|
||||
|
||||
# Set this template as default
|
||||
self.is_default = True
|
||||
|
||||
# Update the system parameter if this is a helpdesk template
|
||||
if self.template_type == 'helpdesk':
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'helpdesk_sale_order_ai.default_prompt_template_id',
|
||||
str(self.id)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to handle default templates"""
|
||||
records = super(AIPromptTemplate, self).create(vals_list)
|
||||
|
||||
# Group templates by type
|
||||
templates_by_type = {}
|
||||
for record in records:
|
||||
if record.is_default:
|
||||
if record.template_type not in templates_by_type:
|
||||
templates_by_type[record.template_type] = []
|
||||
templates_by_type[record.template_type].append(record.id)
|
||||
|
||||
# For each type with new default templates, unset default flag on others of the same type
|
||||
for template_type, template_ids in templates_by_type.items():
|
||||
self.search([
|
||||
('id', 'not in', template_ids),
|
||||
('is_default', '=', True),
|
||||
('template_type', '=', template_type)
|
||||
]).write({'is_default': False})
|
||||
|
||||
return records
|
||||
|
||||
@api.model
|
||||
def get_default_template(self, template_type='helpdesk'):
|
||||
"""Get the default template for the given type"""
|
||||
_logger.info(f"Getting default template for type: {template_type}")
|
||||
try:
|
||||
default_template = self.search([
|
||||
('is_default', '=', True),
|
||||
('template_type', '=', template_type),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
|
||||
_logger.info(f"Found default template: {bool(default_template)}")
|
||||
|
||||
if not default_template:
|
||||
_logger.info("No active default template found, ensuring one exists")
|
||||
# Create a default template if none exists
|
||||
default_template = self._ensure_default_template(template_type)
|
||||
_logger.info(f"Default template after ensure: {bool(default_template)} with ID: {default_template.id if default_template else 'None'}")
|
||||
else:
|
||||
_logger.info(f"Using existing default template with ID: {default_template.id}")
|
||||
|
||||
return default_template
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in get_default_template: {e}", exc_info=True)
|
||||
# Try to create a new template as a fallback
|
||||
try:
|
||||
_logger.info("Attempting to create a new default template as fallback")
|
||||
default_content = self._get_default_template_content(template_type)
|
||||
default_name = f"Fallback {template_type.capitalize()} Template"
|
||||
|
||||
fallback_template = self.create({
|
||||
'name': default_name,
|
||||
'template_type': template_type,
|
||||
'content': default_content,
|
||||
'is_default': True,
|
||||
})
|
||||
_logger.info(f"Created fallback template with ID: {fallback_template.id}")
|
||||
return fallback_template
|
||||
except Exception as e2:
|
||||
_logger.error(f"Failed to create fallback template: {e2}", exc_info=True)
|
||||
return self.browse() # Return empty recordset
|
||||
|
|
@ -1,359 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OpenWebUIClient(models.AbstractModel):
|
||||
"""
|
||||
OpenWebUI client for interacting with the OpenWebUI API.
|
||||
This is a simplified version of the external OpenWebUI client integrated into Odoo.
|
||||
"""
|
||||
_name = 'openwebui.client'
|
||||
_description = 'OpenWebUI Client for AI Services'
|
||||
|
||||
def _get_config(self, raise_if_missing=True):
|
||||
"""
|
||||
Get the OpenWebUI configuration from the system parameters.
|
||||
Uses the dedicated openwebui parameters for API key, base URL and model.
|
||||
|
||||
Args:
|
||||
raise_if_missing: If True, raise an error when API key is missing.
|
||||
If False, return config with empty API key.
|
||||
"""
|
||||
_logger.debug(f"Getting OpenWebUI config (raise_if_missing={raise_if_missing})")
|
||||
try:
|
||||
# Get the config from the ir.config_parameter
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Use dedicated OpenWebUI parameters
|
||||
api_key = IrConfigParam.get_param('openwebui.api_key', False)
|
||||
|
||||
# If OpenWebUI API key is not set, try fallback to OpenAI key
|
||||
if not api_key:
|
||||
api_key = IrConfigParam.get_param('openai.api_key', False)
|
||||
_logger.debug("OpenWebUI API key not found, falling back to OpenAI API key")
|
||||
|
||||
_logger.debug(f"Retrieved API key: {'Present' if api_key else 'Missing'}")
|
||||
|
||||
# Get base URL from dedicated parameter
|
||||
base_url = IrConfigParam.get_param('openwebui.base_url', 'https://ai.bemade.org/api')
|
||||
_logger.debug(f"Using base_url: {base_url}")
|
||||
|
||||
# Get model from dedicated parameter
|
||||
model = IrConfigParam.get_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
|
||||
_logger.debug(f"Using model: {model}")
|
||||
|
||||
if not api_key and raise_if_missing:
|
||||
_logger.error("No API key found in configuration and raise_if_missing=True")
|
||||
raise ValueError("No API key found in configuration")
|
||||
|
||||
config = {
|
||||
'api_key': api_key or '',
|
||||
'base_url': base_url,
|
||||
'model': model
|
||||
}
|
||||
_logger.debug("Successfully retrieved OpenWebUI config")
|
||||
return config
|
||||
except Exception as e:
|
||||
_logger.error(f"Error getting OpenWebUI config: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_available_models(self):
|
||||
"""
|
||||
Fetch available models from the OpenWebUI API.
|
||||
|
||||
Returns:
|
||||
A list of tuples (model_id, model_name) suitable for selection fields
|
||||
"""
|
||||
_logger.info("Starting get_available_models")
|
||||
# Define default_model at the beginning to ensure it's always available
|
||||
default_model = 'anthropic.claude-3-7-sonnet-latest'
|
||||
|
||||
# Create a list with just the default model to ensure it's always available
|
||||
models = [(default_model, default_model)]
|
||||
|
||||
try:
|
||||
# Get config without raising exception if API key is missing
|
||||
config = self._get_config(raise_if_missing=False)
|
||||
|
||||
# Update default_model with configured value and ensure it's in the list
|
||||
if 'model' in config and config['model']:
|
||||
default_model = config['model']
|
||||
# Clear the list and add the current default model
|
||||
models = [(default_model, default_model)]
|
||||
|
||||
_logger.info(f"Default model: {default_model}")
|
||||
|
||||
# If no API key is set, just return the current model
|
||||
if not config.get('api_key'):
|
||||
_logger.warning("No API key configured, only showing current model")
|
||||
return models
|
||||
|
||||
# Add some common models that we know work with OpenWebUI
|
||||
# This ensures we have a good selection even if the API call fails
|
||||
common_models = [
|
||||
('anthropic.claude-3-7-sonnet-latest', 'Claude 3.7 Sonnet'),
|
||||
('anthropic.claude-3-5-sonnet-20240620', 'Claude 3.5 Sonnet'),
|
||||
('anthropic.claude-3-opus-20240229', 'Claude 3 Opus'),
|
||||
('gpt-4o', 'GPT-4o'),
|
||||
('gpt-4-turbo', 'GPT-4 Turbo'),
|
||||
('gpt-3.5-turbo', 'GPT-3.5 Turbo')
|
||||
]
|
||||
|
||||
# Add common models to our list, but keep the default model first
|
||||
for model_id, model_name in common_models:
|
||||
if model_id != default_model: # Avoid duplicates
|
||||
models.append((model_id, model_name))
|
||||
|
||||
# Remove trailing slash if present in base_url
|
||||
base_url = config.get('base_url', 'https://ai.bemade.org/api')
|
||||
if isinstance(base_url, str) and base_url.endswith('/'):
|
||||
base_url = base_url[:-1]
|
||||
_logger.info(f"Using base URL: {base_url}")
|
||||
|
||||
# Check if base_url already contains '/api' to avoid duplicates
|
||||
if '/api' in base_url:
|
||||
endpoints = [
|
||||
'/v1/models',
|
||||
'/models',
|
||||
'/chat/models',
|
||||
'/v1/chat/models'
|
||||
]
|
||||
else:
|
||||
endpoints = [
|
||||
'/v1/models',
|
||||
'/models',
|
||||
'/chat/models',
|
||||
'/api/models',
|
||||
'/api/v1/models',
|
||||
'/api/chat/models'
|
||||
]
|
||||
_logger.debug(f"Will try these endpoints: {endpoints}")
|
||||
|
||||
# Set up authentication headers
|
||||
headers = {
|
||||
"Authorization": f"Bearer {config['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
_logger.debug("Authentication headers set up")
|
||||
|
||||
# Try a simple connection test first with a short timeout
|
||||
try:
|
||||
_logger.info(f"Testing connection to base URL: {base_url}")
|
||||
test_response = requests.get(base_url, timeout=3)
|
||||
_logger.info(f"Base API connection test: {test_response.status_code}")
|
||||
if test_response.status_code >= 400:
|
||||
_logger.warning(f"Base URL returned error status: {test_response.status_code}")
|
||||
return models # Return our predefined models
|
||||
except Exception as e:
|
||||
_logger.warning(f"Could not connect to base API URL: {str(e)}")
|
||||
# Return our predefined models if we can't connect
|
||||
_logger.info("Returning predefined models due to connection error")
|
||||
return models
|
||||
|
||||
# Try each endpoint with a short timeout
|
||||
_logger.info("Starting to try each endpoint for models")
|
||||
success = False
|
||||
|
||||
for endpoint in endpoints:
|
||||
url = f"{base_url}{endpoint}"
|
||||
_logger.info(f"Trying endpoint: {url}")
|
||||
|
||||
try:
|
||||
_logger.debug(f"Making request to {url} with timeout=5")
|
||||
response = requests.get(url, headers=headers, timeout=5)
|
||||
_logger.info(f"Response status code: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
_logger.info(f"Endpoint {endpoint} returned non-200 status code: {response.status_code}")
|
||||
continue
|
||||
|
||||
# Check if response is empty
|
||||
if not response.text or response.text.strip() == '':
|
||||
_logger.warning(f"Empty response from {url}")
|
||||
continue
|
||||
|
||||
# Try to parse the response as JSON
|
||||
try:
|
||||
_logger.debug("Parsing response as JSON")
|
||||
response_data = response.json()
|
||||
_logger.debug(f"Response data type: {type(response_data)}")
|
||||
|
||||
# If we got an empty object or list, skip
|
||||
if (isinstance(response_data, dict) and not response_data) or \
|
||||
(isinstance(response_data, list) and not response_data):
|
||||
_logger.warning(f"Empty JSON object/array from {url}")
|
||||
continue
|
||||
|
||||
except json.JSONDecodeError as je:
|
||||
_logger.error(f"Failed to parse JSON from {url}: {je}")
|
||||
continue
|
||||
|
||||
# Extract model information from the response
|
||||
model_ids = []
|
||||
|
||||
# Handle different response formats
|
||||
if isinstance(response_data, dict):
|
||||
_logger.debug("Response is a dictionary")
|
||||
# OpenAI format
|
||||
if "data" in response_data and isinstance(response_data["data"], list):
|
||||
_logger.debug("Found OpenAI format response with 'data' key")
|
||||
for model in response_data["data"]:
|
||||
if isinstance(model, dict) and "id" in model:
|
||||
model_id = model["id"]
|
||||
model_name = model.get("name", model_id)
|
||||
model_ids.append((model_id, model_name))
|
||||
# Another common format
|
||||
elif "models" in response_data and isinstance(response_data["models"], list):
|
||||
_logger.debug("Found response with 'models' key")
|
||||
for model in response_data["models"]:
|
||||
if isinstance(model, dict) and "id" in model:
|
||||
model_id = model["id"]
|
||||
model_name = model.get("name", model_id)
|
||||
model_ids.append((model_id, model_name))
|
||||
else:
|
||||
_logger.debug(f"Dictionary response keys: {list(response_data.keys())}")
|
||||
elif isinstance(response_data, list):
|
||||
_logger.debug("Response is a list")
|
||||
# Simple list format
|
||||
for model in response_data:
|
||||
if isinstance(model, dict) and "id" in model:
|
||||
model_id = model["id"]
|
||||
model_name = model.get("name", model_id)
|
||||
model_ids.append((model_id, model_name))
|
||||
else:
|
||||
_logger.warning(f"Unexpected response data type: {type(response_data)}")
|
||||
|
||||
if model_ids:
|
||||
_logger.info(f"Found {len(model_ids)} models from endpoint {url}")
|
||||
# Add all found models to our list, avoiding duplicates
|
||||
for model_tuple in model_ids:
|
||||
if model_tuple not in models:
|
||||
models.append(model_tuple)
|
||||
success = True
|
||||
break
|
||||
else:
|
||||
_logger.warning(f"No models found in response from {url}")
|
||||
|
||||
except requests.RequestException as e:
|
||||
_logger.error(f"Request error with endpoint {endpoint}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error with endpoint {endpoint}: {e}")
|
||||
continue
|
||||
|
||||
# If we couldn't find any models from API, we still have our predefined models
|
||||
_logger.info(f"Total models found: {len(models)}")
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in get_available_models: {e}")
|
||||
# Return just the default model on any error
|
||||
return [(default_model, default_model)]
|
||||
|
||||
def chat_completion(self, messages, model=None):
|
||||
"""
|
||||
Send a chat completion request to the OpenWebUI API.
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries with 'role' and 'content' keys
|
||||
model: Model to use (defaults to the configured model)
|
||||
|
||||
Returns:
|
||||
The response from the OpenWebUI API
|
||||
"""
|
||||
_logger.info("Starting chat_completion request")
|
||||
try:
|
||||
# For chat completion, we do need a valid API key
|
||||
_logger.info("Getting config for chat completion")
|
||||
config = self._get_config(raise_if_missing=True)
|
||||
|
||||
# Use the provided model or fall back to the configured model
|
||||
model = model or config['model']
|
||||
_logger.info(f"Using model: {model}")
|
||||
|
||||
# Remove trailing slash if present in base_url
|
||||
base_url = config['base_url']
|
||||
if isinstance(base_url, str) and base_url.endswith('/'):
|
||||
base_url = base_url[:-1]
|
||||
_logger.info(f"Using base URL: {base_url}")
|
||||
|
||||
# Construct the full URL based on the API provider
|
||||
if 'openai.com' in base_url:
|
||||
# Standard OpenAI API format
|
||||
url = f"{base_url}/chat/completions"
|
||||
elif 'bemade.org' in base_url:
|
||||
# For Bemade's API, try without the v1 path as it's returning 405
|
||||
url = f"{base_url}/chat/completions"
|
||||
else:
|
||||
# Generic format, try standard OpenAI path
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
_logger.info(f"Full API URL: {url}")
|
||||
|
||||
# Set up authentication headers
|
||||
headers = {
|
||||
"Authorization": f"Bearer {config['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
_logger.debug("Authentication headers set up")
|
||||
|
||||
# Create the payload
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.7, # Add reasonable temperature for more consistent results
|
||||
"max_tokens": 4000 # Ensure we get enough tokens for a complete response
|
||||
}
|
||||
|
||||
_logger.info(f"OpenWebUI API request to: {url}")
|
||||
_logger.debug(f"OpenWebUI API payload: {payload}")
|
||||
|
||||
try:
|
||||
# Make the HTTP request
|
||||
_logger.info("Sending POST request to OpenWebUI API")
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=60.0)
|
||||
_logger.info(f"Response status code: {response.status_code}")
|
||||
|
||||
# Log response content for debugging
|
||||
_logger.debug(f"Response content (first 500 chars): {response.text[:500]}")
|
||||
|
||||
# Raise an exception for any HTTP error
|
||||
response.raise_for_status()
|
||||
_logger.info("Response status check passed")
|
||||
|
||||
# Parse the JSON response
|
||||
try:
|
||||
_logger.debug("Parsing response as JSON")
|
||||
response_data = response.json()
|
||||
except json.JSONDecodeError as je:
|
||||
_logger.error(f"Failed to parse JSON response: {je}", exc_info=True)
|
||||
_logger.error(f"Response content: {response.text[:500]}")
|
||||
return ""
|
||||
|
||||
_logger.debug(f"Response data type: {type(response_data)}")
|
||||
if isinstance(response_data, dict):
|
||||
_logger.debug(f"Response keys: {list(response_data.keys())}")
|
||||
|
||||
# Extract the content from the response
|
||||
if response_data and 'choices' in response_data and response_data['choices']:
|
||||
_logger.info("Successfully extracted content from response")
|
||||
return response_data['choices'][0]['message']['content']
|
||||
else:
|
||||
_logger.error(f"Invalid response format from OpenWebUI API: {response_data}")
|
||||
return ""
|
||||
|
||||
except requests.RequestException as re:
|
||||
_logger.error(f"Request error calling OpenWebUI API: {re}", exc_info=True)
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error in API request: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in chat_completion: {e}", exc_info=True)
|
||||
raise
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# AI API Configuration (unified for all AI services)
|
||||
openai_api_key = fields.Char(
|
||||
string='AI API Key',
|
||||
help='API key for AI services (OpenAI, OpenWebUI, Claude)',
|
||||
config_parameter='openwebui.api_key',
|
||||
)
|
||||
|
||||
openai_base_url = fields.Char(
|
||||
string='AI Base URL',
|
||||
help='Base URL for AI API',
|
||||
default='https://api.openai.com/v1', # Default OpenAI endpoint
|
||||
config_parameter='openwebui.base_url',
|
||||
)
|
||||
|
||||
# Use Selection field with dynamic options from OpenWebUI client
|
||||
openai_model = fields.Selection(
|
||||
selection='_get_openwebui_models',
|
||||
string='AI Model',
|
||||
help='Model to use for AI API calls',
|
||||
default='anthropic.claude-3-7-sonnet-latest',
|
||||
config_parameter='openwebui.model',
|
||||
)
|
||||
|
||||
# Helpdesk AI fields
|
||||
helpdesk_use_ai_sale_orders = fields.Boolean(
|
||||
string='Use AI for Sale Orders',
|
||||
help='If checked, the system will use AI to automatically generate sale orders from helpdesk ticket descriptions.',
|
||||
config_parameter='helpdesk_sale_order_ai.use_ai_sale_orders',
|
||||
)
|
||||
|
||||
# Add a field to select the prompt template directly
|
||||
# In Odoo 18, we need to ensure this field is properly defined
|
||||
ai_prompt_template_id = fields.Integer(
|
||||
string='Default AI Prompt Template ID',
|
||||
help='Default template ID for the prompt sent to the AI.',
|
||||
compute='_compute_ai_prompt_template_id',
|
||||
inverse='_inverse_ai_prompt_template_id',
|
||||
store=False, # Don't store this field to avoid constraint issues
|
||||
)
|
||||
|
||||
# Actual relation field for the template, but not stored in database
|
||||
ai_prompt_template_relation = fields.Many2one(
|
||||
'openwebui.prompt.template',
|
||||
string='Default AI Prompt Template',
|
||||
help='Default template for the prompt sent to the AI.',
|
||||
compute='_compute_ai_prompt_template_relation',
|
||||
domain="[('template_type', '=', 'helpdesk')]",
|
||||
store=False, # Don't store this field to avoid constraint issues
|
||||
)
|
||||
|
||||
# Display field for backward compatibility
|
||||
helpdesk_ai_template_display = fields.Char(
|
||||
string='Default AI Prompt Template',
|
||||
help='Default template for the prompt sent to the AI.',
|
||||
compute='_compute_helpdesk_ai_template_display',
|
||||
readonly=True,
|
||||
store=False, # Important: don't store this field in the database
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_openwebui_models(self):
|
||||
"""Get available models from OpenWebUI API"""
|
||||
try:
|
||||
client = self.env['openwebui.client']
|
||||
models = client.get_available_models()
|
||||
return models
|
||||
except Exception as e:
|
||||
_logger.error(f"Error fetching OpenWebUI models: {e}")
|
||||
# Return default model on error
|
||||
default_model = 'anthropic.claude-3-7-sonnet-latest'
|
||||
return [(default_model, default_model)]
|
||||
|
||||
def _compute_ai_prompt_template_id(self):
|
||||
"""Compute the template ID from system parameter"""
|
||||
for record in self:
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
# Convert to integer if it's a string
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
record.ai_prompt_template_id = template_id_int
|
||||
except (ValueError, TypeError):
|
||||
record.ai_prompt_template_id = False
|
||||
else:
|
||||
record.ai_prompt_template_id = False
|
||||
|
||||
def _inverse_ai_prompt_template_id(self):
|
||||
"""Save the template ID to system parameter"""
|
||||
for record in self:
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
if record.ai_prompt_template_id:
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(record.ai_prompt_template_id))
|
||||
else:
|
||||
# Try to get a default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
|
||||
def _compute_ai_prompt_template_relation(self):
|
||||
"""Compute the template relation from the ID"""
|
||||
for record in self:
|
||||
if record.ai_prompt_template_id:
|
||||
template = self.env['openwebui.prompt.template'].browse(record.ai_prompt_template_id)
|
||||
if template.exists():
|
||||
record.ai_prompt_template_relation = template.id
|
||||
else:
|
||||
record.ai_prompt_template_relation = False
|
||||
else:
|
||||
record.ai_prompt_template_relation = False
|
||||
|
||||
@api.depends('ai_prompt_template_relation')
|
||||
def _compute_helpdesk_ai_template_display(self):
|
||||
"""Compute the template name from the selected template"""
|
||||
for record in self:
|
||||
if record.ai_prompt_template_relation:
|
||||
record.helpdesk_ai_template_display = record.ai_prompt_template_relation.name
|
||||
else:
|
||||
# Try to get from system parameter for backward compatibility
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
# Convert to integer if it's a string
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
|
||||
# Try to find the template in the new model
|
||||
template = self.env['openwebui.prompt.template'].browse(template_id_int)
|
||||
if template.exists():
|
||||
record.helpdesk_ai_template_display = template.name
|
||||
else:
|
||||
record.helpdesk_ai_template_display = f'Template #{template_id} (not found)'
|
||||
except (ValueError, TypeError):
|
||||
record.helpdesk_ai_template_display = f'Template #{template_id} (invalid)'
|
||||
else:
|
||||
record.helpdesk_ai_template_display = 'Default Template'
|
||||
|
||||
@api.model
|
||||
def _set_default_values(self):
|
||||
"""Set default values for configuration parameters"""
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
base_url = IrConfigParam.get_param('openwebui.base_url')
|
||||
model = IrConfigParam.get_param('openwebui.model')
|
||||
|
||||
# Check for old parameters and migrate them
|
||||
old_api_key = IrConfigParam.get_param('openai.api_key', False)
|
||||
old_base_url = IrConfigParam.get_param('openai.base_url', False)
|
||||
old_model = IrConfigParam.get_param('openai.model', False)
|
||||
|
||||
# Migrate old parameters to new ones if they exist
|
||||
if old_api_key and not IrConfigParam.get_param('openwebui.api_key', False):
|
||||
IrConfigParam.set_param('openwebui.api_key', old_api_key)
|
||||
|
||||
if old_base_url and not base_url:
|
||||
IrConfigParam.set_param('openwebui.base_url', old_base_url)
|
||||
|
||||
if old_model and not model:
|
||||
IrConfigParam.set_param('openwebui.model', old_model)
|
||||
|
||||
# Set defaults if not already set
|
||||
if not base_url:
|
||||
IrConfigParam.set_param('openwebui.base_url', 'https://api.openai.com/v1')
|
||||
|
||||
if not model:
|
||||
IrConfigParam.set_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
|
||||
|
||||
# Ensure there's a default template
|
||||
template_model = self.env['openwebui.prompt.template']
|
||||
template_model._ensure_default_template('helpdesk')
|
||||
default_template = template_model.get_default_template('helpdesk')
|
||||
|
||||
# Set the default template ID in config parameters if not set
|
||||
default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
|
||||
if not default_template_id and default_template:
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
|
||||
@api.model
|
||||
def create(self, vals_list):
|
||||
"""Set default values when creating settings"""
|
||||
self._set_default_values()
|
||||
return super(ResConfigSettings, self).create(vals_list)
|
||||
|
||||
def set_values(self):
|
||||
"""Set values from the settings form"""
|
||||
_logger.info("Starting set_values in ResConfigSettings")
|
||||
try:
|
||||
# First, explicitly save the API key, base URL, and model to ensure they're saved
|
||||
# even if there's an error with the template handling
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Save API key, base URL, and model directly with explicit error handling
|
||||
# API Key - most critical setting
|
||||
if hasattr(self, 'openai_api_key'):
|
||||
_logger.info(f"Saving API key (present: {bool(self.openai_api_key)})")
|
||||
# Always set the parameter, even if None or False, to clear previous value if needed
|
||||
api_key_value = self.openai_api_key if self.openai_api_key else ''
|
||||
IrConfigParam.set_param('openai.api_key', api_key_value)
|
||||
# Verify it was saved
|
||||
saved_key = IrConfigParam.get_param('openai.api_key', '')
|
||||
_logger.info(f"API key saved successfully: {bool(saved_key)}")
|
||||
|
||||
# Base URL
|
||||
if hasattr(self, 'openai_base_url'):
|
||||
_logger.info(f"Saving base URL: {self.openai_base_url}")
|
||||
base_url_value = self.openai_base_url if self.openai_base_url else 'https://ai.bemade.org/api'
|
||||
IrConfigParam.set_param('openai.base_url', base_url_value)
|
||||
# Verify it was saved
|
||||
saved_url = IrConfigParam.get_param('openai.base_url', '')
|
||||
_logger.info(f"Base URL saved: {saved_url}")
|
||||
|
||||
# Model
|
||||
if hasattr(self, 'openai_model'):
|
||||
_logger.info(f"Saving model: {self.openai_model}")
|
||||
model_value = self.openai_model if self.openai_model else 'anthropic.claude-3-7-sonnet-latest'
|
||||
IrConfigParam.set_param('openai.model', model_value)
|
||||
# Verify it was saved
|
||||
saved_model = IrConfigParam.get_param('openai.model', '')
|
||||
_logger.info(f"Model saved: {saved_model}")
|
||||
|
||||
_logger.info(f"Current settings values: openai_api_key={bool(self.openai_api_key)}, openai_base_url={self.openai_base_url}, openai_model={self.openai_model}")
|
||||
_logger.info(f"Template ID: {self.ai_prompt_template_id if self.ai_prompt_template_id else 'None'}")
|
||||
|
||||
# Force a commit to ensure parameters are saved to database
|
||||
self.env.cr.commit()
|
||||
_logger.info("Committed parameter changes to database")
|
||||
|
||||
# Now call the super method
|
||||
res = super(ResConfigSettings, self).set_values()
|
||||
_logger.info("Super set_values completed successfully")
|
||||
|
||||
# Handle the template ID - either save the selected one or ensure a default exists
|
||||
try:
|
||||
if self.ai_prompt_template_id:
|
||||
_logger.info(f"Saving template ID {self.ai_prompt_template_id} to system parameters")
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.ai_prompt_template_id))
|
||||
else:
|
||||
# If no template is selected, ensure a default one exists and use that
|
||||
_logger.info("No template selected, getting default template")
|
||||
template_model = self.env['openwebui.prompt.template']
|
||||
try:
|
||||
default_template = template_model.get_default_template('helpdesk')
|
||||
if default_template:
|
||||
_logger.info(f"Using default template ID {default_template.id}")
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
else:
|
||||
_logger.warning("No default template found")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error getting default template: {e}", exc_info=True)
|
||||
# Continue execution even if there's an error with the template
|
||||
except Exception as template_error:
|
||||
_logger.error(f"Error handling template: {template_error}", exc_info=True)
|
||||
# Continue execution even if there's an error with the template
|
||||
|
||||
# Force another commit to ensure all changes are saved
|
||||
self.env.cr.commit()
|
||||
_logger.info("Final commit completed")
|
||||
|
||||
# Double-check that the API key was saved
|
||||
final_api_key = IrConfigParam.get_param('openai.api_key', '')
|
||||
_logger.info(f"Final API key check - key exists: {bool(final_api_key)}")
|
||||
|
||||
_logger.info("set_values completed successfully")
|
||||
return res
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in set_values: {e}", exc_info=True)
|
||||
# Try to save API key directly even if there was an error
|
||||
try:
|
||||
if hasattr(self, 'openai_api_key'):
|
||||
api_key_value = self.openai_api_key if self.openai_api_key else ''
|
||||
self.env['ir.config_parameter'].sudo().set_param('openai.api_key', api_key_value)
|
||||
self.env.cr.commit() # Force commit to save the key
|
||||
_logger.info("Saved API key directly after error")
|
||||
except Exception as key_error:
|
||||
_logger.error(f"Failed to save API key after error: {key_error}")
|
||||
raise
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
"""Get values for the settings form"""
|
||||
res = super(ResConfigSettings, self).get_values()
|
||||
|
||||
# Ensure default values are set
|
||||
self._set_default_values()
|
||||
|
||||
# Get all OpenAI settings from system parameters
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Get OpenAI API key, base URL, and model
|
||||
_logger.info("Retrieving OpenAI settings from system parameters")
|
||||
api_key = IrConfigParam.get_param('openai.api_key', False)
|
||||
base_url = IrConfigParam.get_param('openai.base_url', 'https://ai.bemade.org/api')
|
||||
model = IrConfigParam.get_param('openai.model', 'anthropic.claude-3-7-sonnet-latest')
|
||||
|
||||
# Set the values in the result dictionary
|
||||
res.update({
|
||||
'openai_api_key': api_key,
|
||||
'openai_base_url': base_url,
|
||||
'openai_model': model,
|
||||
})
|
||||
|
||||
_logger.info(f"Retrieved settings: API key present: {bool(api_key)}, base_url: {base_url}, model: {model}")
|
||||
|
||||
# Get the template ID from the system parameter
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
# Check if the template exists
|
||||
template = self.env['openwebui.prompt.template'].browse(template_id_int)
|
||||
if template.exists():
|
||||
res['ai_prompt_template_id'] = template_id_int
|
||||
else:
|
||||
# If template doesn't exist, get a default one
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
res['ai_prompt_template_id'] = default_template.id
|
||||
# Update the system parameter
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
except (ValueError, TypeError):
|
||||
_logger.error(f"Invalid template ID in system parameter: {template_id}")
|
||||
# Get a default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
res['ai_prompt_template_id'] = default_template.id
|
||||
# Update the system parameter
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
else:
|
||||
# No template ID in system parameters, get a default one
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
res['ai_prompt_template_id'] = default_template.id
|
||||
# Update the system parameter
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
|
||||
return res
|
||||
|
||||
def action_open_prompt_templates(self):
|
||||
"""Open the prompt templates management view"""
|
||||
self.ensure_one()
|
||||
|
||||
return {
|
||||
'name': _('AI Prompt Templates'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'openwebui.prompt.template',
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_prompt_template(self, template_type='helpdesk'):
|
||||
"""Get the prompt template content from the selected template or default"""
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
# Make sure the template model exists first
|
||||
self.env['openwebui.prompt.template']._ensure_default_template(template_type)
|
||||
# The template_id is already an integer in the database now
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
template = self.env['openwebui.prompt.template'].browse(template_id_int)
|
||||
if template.exists():
|
||||
return template.content
|
||||
except (ValueError, TypeError) as e:
|
||||
_logger.error(f"Error retrieving prompt template: {e}")
|
||||
pass
|
||||
|
||||
# Fallback to default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template(template_type)
|
||||
return default_template.content if default_template else ""
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_openwebui_prompt_template_user,openwebui.prompt.template.user,model_openwebui_prompt_template,base.group_user,1,0,0,0
|
||||
access_openwebui_prompt_template_admin,openwebui.prompt.template.admin,model_openwebui_prompt_template,base.group_system,1,1,1,1
|
||||
access_openwebui_client_user,openwebui.client.user,model_openwebui_client,base.group_user,1,0,0,0
|
||||
access_openwebui_client_admin,openwebui.client.admin,model_openwebui_client,base.group_system,1,1,1,1
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- AI Prompt Template Form View -->
|
||||
<record id="view_openai_prompt_template_form" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.form</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Prompt Template">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="set_as_default" type="object"
|
||||
class="oe_stat_button" icon="fa-star"
|
||||
invisible="is_default">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Set as Default</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="set_as_default" type="object"
|
||||
class="oe_stat_button" icon="fa-star" disabled="1"
|
||||
invisible="not is_default">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Default Template</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Template Name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="template_type"/>
|
||||
<field name="sequence" groups="base.group_no_one"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="is_default" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Template Content" name="template_content">
|
||||
<div class="text-muted mb-3">
|
||||
<div invisible="template_type != 'helpdesk'">
|
||||
Use placeholders like {description}, {customer}, etc. to include ticket information in the prompt.
|
||||
</div>
|
||||
<div invisible="template_type != 'general'">
|
||||
Use placeholders like {content} to include content in the prompt.
|
||||
</div>
|
||||
</div>
|
||||
<field name="content" widget="text" class="oe_edit_only" style="min-height: 300px;"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template List View -->
|
||||
<record id="view_openai_prompt_template_list" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.list</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="template_type"/>
|
||||
<field name="is_default" string="Default"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template Search View -->
|
||||
<record id="view_openai_prompt_template_search" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.search</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Prompt Templates">
|
||||
<field name="name"/>
|
||||
<filter string="Default" name="default" domain="[('is_default', '=', True)]"/>
|
||||
<filter string="Helpdesk" name="helpdesk" domain="[('template_type', '=', 'helpdesk')]"/>
|
||||
<filter string="General" name="general" domain="[('template_type', '=', 'general')]"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Template Type" name="group_by_type" domain="[]" context="{'group_by': 'template_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template Action -->
|
||||
<record id="action_openai_prompt_template" model="ir.actions.act_window">
|
||||
<field name="name">AI Prompt Templates</field>
|
||||
<field name="res_model">openwebui.prompt.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new AI prompt template
|
||||
</p>
|
||||
<p>
|
||||
Define templates for AI prompts used in various modules.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item under Settings -->
|
||||
<menuitem id="menu_openai_prompt_template"
|
||||
name="AI Prompt Templates"
|
||||
parent="base.menu_administration"
|
||||
action="action_openai_prompt_template"
|
||||
sequence="50"/>
|
||||
</odoo>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.openai.connector</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority">20</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="AI Services" string="AI Services" name="openwebui_connector" logo="/base/static/description/settings.png">
|
||||
<!-- API Configuration Block -->
|
||||
<block title="AI API Configuration" name="openai_settings_container">
|
||||
<setting id="openai_api_settings">
|
||||
<span class="o_form_label">API Configuration</span>
|
||||
<div class="text-muted">
|
||||
Configure your AI API credentials (OpenAI, OpenWebUI, Claude)
|
||||
</div>
|
||||
<div class="content-group mt16">
|
||||
<div class="row mt16">
|
||||
<label for="openai_api_key" class="col-lg-3 o_light_label">API Key</label>
|
||||
<field name="openai_api_key" password="True"/>
|
||||
</div>
|
||||
<div class="row mt16">
|
||||
<label for="openai_base_url" class="col-lg-3 o_light_label">API Base URL</label>
|
||||
<field name="openai_base_url" placeholder="https://ai.bemade.org/api"/>
|
||||
</div>
|
||||
<div class="row mt16">
|
||||
<label for="openai_model" class="col-lg-3 o_light_label">AI Model</label>
|
||||
<field name="openai_model" placeholder="anthropic.claude-3-7-sonnet-latest"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Helpdesk AI Settings Block -->
|
||||
<block title="Helpdesk AI Settings" name="helpdesk_ai_settings_container">
|
||||
<setting id="ai_sales_order_toggle">
|
||||
<field name="helpdesk_use_ai_sale_orders"/>
|
||||
<label for="helpdesk_use_ai_sale_orders"/>
|
||||
<div class="text-muted">
|
||||
Enable AI-powered sales order generation from helpdesk tickets
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="ai_prompt_template" invisible="not helpdesk_use_ai_sale_orders">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_prompt_template_relation"/>
|
||||
<div class="text-muted">
|
||||
Default template for the prompt sent to the AI
|
||||
</div>
|
||||
<div class="mt8 d-flex align-items-center">
|
||||
<!-- Select the template directly -->
|
||||
<field name="ai_prompt_template_relation"/>
|
||||
<button name="action_open_prompt_templates" type="object" class="btn btn-link ps-2">
|
||||
<i class="fa fa-external-link" title="Manage Templates"/> Manage Templates
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-muted">
|
||||
<small>The template content is stored in the system and can be accessed programmatically.</small>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue