[ADD] openwebui_connector, helpdesk_sale_order_ai: AI-powered sales order generation from helpdesk tickets
This commit introduces AI integration for helpdesk tickets to automatically generate sales orders: - openwebui_connector: New module providing integration with OpenWebUI AI service * Configurable API connection (key, base URL, model) * AI prompt template system for reusable prompts * Uses Claude 3 Sonnet model by default - helpdesk_sale_order_ai: Extends helpdesk_sale_order with AI capabilities * AI-powered analysis of ticket content to suggest products * Smart product quantity parsing from various formats * Dedicated UI tab for AI suggestions in helpdesk tickets * Auto-creation of sales orders with matched products The integration streamlines the process of converting customer support requests into sales opportunities.
This commit is contained in:
parent
07c712f0b8
commit
ed9dda9bea
38 changed files with 2839 additions and 118 deletions
|
|
@ -1,3 +1,38 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import views
|
||||
|
||||
def post_init_hook(cr, registry=None):
|
||||
"""Initialize default AI prompt template after module installation
|
||||
|
||||
Note: This function can be called with either one argument (env) or two arguments (cr, registry)
|
||||
to accommodate different Odoo hook calling conventions.
|
||||
"""
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("=== START: post_init_hook ====")
|
||||
|
||||
try:
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
# Handle different calling conventions
|
||||
if hasattr(cr, 'env'): # cr is actually an env
|
||||
env = cr
|
||||
_logger.info("Using provided environment")
|
||||
elif registry: # We have both cr and registry
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
_logger.info("Created environment from cursor and registry")
|
||||
else:
|
||||
_logger.warning("Cannot create environment, skipping template creation")
|
||||
return
|
||||
|
||||
_logger.info("Calling _ensure_default_template")
|
||||
env['openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
_logger.info("Default template ensured successfully")
|
||||
_logger.info("=== END: post_init_hook ====")
|
||||
except Exception as e:
|
||||
_logger.error(f"ERROR in post_init_hook: {str(e)}")
|
||||
_logger.exception("Exception traceback:")
|
||||
# Don't raise the exception to prevent installation failure
|
||||
return
|
||||
|
|
|
|||
|
|
@ -16,12 +16,16 @@
|
|||
'maintainer': 'it@bemade.org',
|
||||
'depends': [
|
||||
'helpdesk_sale_order',
|
||||
'openai_connector', # Supposant qu'un module de connexion OpenAI existe
|
||||
'openwebui_connector',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/helpdesk_team_views.xml',
|
||||
'views/helpdesk_ticket_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/ai_prompt_template_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'post_init_hook': 'post_init_hook',
|
||||
}
|
||||
|
|
|
|||
7
helpdesk_sale_order_ai/data/config.json
Normal file
7
helpdesk_sale_order_ai/data/config.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"OpenWebUI": {
|
||||
"api_key": "",
|
||||
"base_url": "https://ai.bemade.org/api",
|
||||
"model": "anthropic.claude-3-7-sonnet-latest"
|
||||
}
|
||||
}
|
||||
2
helpdesk_sale_order_ai/migrations/1.0/__init__.py
Normal file
2
helpdesk_sale_order_ai/migrations/1.0/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is intentionally left empty to make the directory a proper Python package
|
||||
104
helpdesk_sale_order_ai/migrations/1.0/post-migration.py
Normal file
104
helpdesk_sale_order_ai/migrations/1.0/post-migration.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run after the module update
|
||||
# It will ensure the prompt templates are properly set up
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Ensure prompt templates are properly set up after migration
|
||||
"""
|
||||
# Check if we have any prompt templates
|
||||
cr.execute("""
|
||||
SELECT COUNT(*) FROM helpdesk_ai_prompt_template
|
||||
""")
|
||||
|
||||
template_count = cr.fetchone()[0]
|
||||
|
||||
if template_count == 0:
|
||||
# Create a default template if none exists
|
||||
_logger.info("No prompt templates found, creating default template")
|
||||
|
||||
default_template_name = "Default Sales Order Template"
|
||||
default_template_content = """
|
||||
You are a sales order assistant. Your task is to analyze the customer's request and suggest products or services that would meet their needs.
|
||||
|
||||
Please provide a list of products or services in the following format:
|
||||
1. Product Name | Quantity | Description
|
||||
2. Product Name | Quantity | Description
|
||||
|
||||
If you're not sure about a specific product, suggest a generic service item with a description of what it should accomplish.
|
||||
"""
|
||||
|
||||
# Insert the default template
|
||||
cr.execute("""
|
||||
INSERT INTO helpdesk_ai_prompt_template (name, content, create_date, write_date)
|
||||
VALUES (%s, %s, now(), now())
|
||||
RETURNING id
|
||||
""", (default_template_name, default_template_content))
|
||||
|
||||
template_id = cr.fetchone()[0]
|
||||
|
||||
# Set this as the default template
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (str(template_id),))
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
|
||||
'Post-migration: Created default prompt template with ID ' || %s,
|
||||
'/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 50, 'migrate')
|
||||
""", (str(template_id),))
|
||||
else:
|
||||
# Check if we have a default template set
|
||||
cr.execute("""
|
||||
SELECT value FROM ir_config_parameter
|
||||
WHERE key = 'helpdesk_sale_order_ai.default_prompt_template_id'
|
||||
""")
|
||||
|
||||
default_template_id = cr.fetchone()
|
||||
|
||||
if not default_template_id:
|
||||
# Get the first template and set it as default
|
||||
cr.execute("""
|
||||
SELECT id FROM helpdesk_ai_prompt_template
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
template_id = cr.fetchone()[0]
|
||||
|
||||
# Set this as the default template
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('helpdesk_sale_order_ai.default_prompt_template_id', %s, now(), now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (str(template_id),))
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'helpdesk_sale_order_ai', 'server', current_database(), 'info',
|
||||
'Post-migration: Set existing template with ID ' || %s || ' as default',
|
||||
'/addons/helpdesk_sale_order_ai/migrations/1.0/post-migration.py', 75, 'migrate')
|
||||
""", (str(template_id),))
|
||||
else:
|
||||
_logger.info(f"Default prompt template already set: {default_template_id[0]}")
|
||||
|
||||
# Ensure the system parameters are properly set up
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('openai.base_url', 'https://ai.bemade.org/api', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('openai.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
111
helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py
Normal file
111
helpdesk_sale_order_ai/migrations/15.0.1.0/post-migrate.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Migrate helpdesk.ai.prompt.template records to openwebui.prompt.template
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
|
||||
_logger.info("Starting migration of AI prompt templates")
|
||||
|
||||
# Check if the old model exists
|
||||
cr.execute("SELECT 1 FROM ir_model WHERE model = 'helpdesk.ai.prompt.template'")
|
||||
if not cr.fetchone():
|
||||
_logger.info("No helpdesk.ai.prompt.template model found, skipping migration")
|
||||
return
|
||||
|
||||
# Check if the new model exists
|
||||
cr.execute("SELECT 1 FROM ir_model WHERE model = 'openwebui.prompt.template'")
|
||||
if not cr.fetchone():
|
||||
_logger.warning("openwebui.prompt.template model not found, cannot migrate")
|
||||
return
|
||||
|
||||
# Get all templates from the old model
|
||||
cr.execute("""
|
||||
SELECT id, name, content, is_default, active, sequence
|
||||
FROM helpdesk_ai_prompt_template
|
||||
""")
|
||||
old_templates = cr.fetchall()
|
||||
|
||||
if not old_templates:
|
||||
_logger.info("No templates found in helpdesk.ai.prompt.template, skipping migration")
|
||||
return
|
||||
|
||||
_logger.info(f"Found {len(old_templates)} templates to migrate")
|
||||
|
||||
# For each template, create a corresponding record in the new model
|
||||
for template_id, name, content, is_default, active, sequence in old_templates:
|
||||
# Check if a template with the same name already exists in the new model
|
||||
cr.execute("""
|
||||
SELECT id FROM openwebui_prompt_template
|
||||
WHERE name = %s AND template_type = 'helpdesk'
|
||||
""", (name,))
|
||||
existing = cr.fetchone()
|
||||
|
||||
if existing:
|
||||
_logger.info(f"Template '{name}' already exists in openwebui.prompt.template, updating")
|
||||
cr.execute("""
|
||||
UPDATE openwebui_prompt_template
|
||||
SET content = %s, is_default = %s, active = %s, sequence = %s
|
||||
WHERE id = %s
|
||||
""", (content, is_default, active, sequence, existing[0]))
|
||||
else:
|
||||
_logger.info(f"Creating new template '{name}' in openwebui.prompt.template")
|
||||
cr.execute("""
|
||||
INSERT INTO openwebui_prompt_template
|
||||
(name, content, is_default, active, sequence, template_type, create_date, write_date)
|
||||
VALUES (%s, %s, %s, %s, %s, 'helpdesk', now(), now())
|
||||
""", (name, content, is_default, active, sequence))
|
||||
|
||||
# Update system parameters that reference the old model
|
||||
cr.execute("""
|
||||
SELECT key, value FROM ir_config_parameter
|
||||
WHERE key LIKE 'helpdesk_sale_order_ai.%_prompt_template_id'
|
||||
""")
|
||||
params = cr.fetchall()
|
||||
|
||||
for key, value in params:
|
||||
if value and value.isdigit():
|
||||
# Get the name of the template
|
||||
cr.execute("""
|
||||
SELECT name FROM helpdesk_ai_prompt_template
|
||||
WHERE id = %s
|
||||
""", (int(value),))
|
||||
template_name = cr.fetchone()
|
||||
|
||||
if template_name:
|
||||
# Find the corresponding template in the new model
|
||||
cr.execute("""
|
||||
SELECT id FROM openwebui_prompt_template
|
||||
WHERE name = %s AND template_type = 'helpdesk'
|
||||
""", (template_name[0],))
|
||||
new_template_id = cr.fetchone()
|
||||
|
||||
if new_template_id:
|
||||
new_key = key.replace('helpdesk_sale_order_ai', 'openwebui_connector')
|
||||
_logger.info(f"Updating system parameter {key} to {new_key} with value {new_template_id[0]}")
|
||||
|
||||
# Check if the new parameter already exists
|
||||
cr.execute("""
|
||||
SELECT id FROM ir_config_parameter
|
||||
WHERE key = %s
|
||||
""", (new_key,))
|
||||
existing_param = cr.fetchone()
|
||||
|
||||
if existing_param:
|
||||
cr.execute("""
|
||||
UPDATE ir_config_parameter
|
||||
SET value = %s
|
||||
WHERE id = %s
|
||||
""", (str(new_template_id[0]), existing_param[0]))
|
||||
else:
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value)
|
||||
VALUES (%s, %s)
|
||||
""", (new_key, str(new_template_id[0])))
|
||||
|
||||
_logger.info("Migration of AI prompt templates completed")
|
||||
43
helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py
Normal file
43
helpdesk_sale_order_ai/migrations/15.0.1.0/pre-migrate.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Migrate system parameters from helpdesk OpenWebUI client to centralized OpenAI client
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
|
||||
_logger.info("Starting migration of OpenWebUI configuration parameters")
|
||||
|
||||
# Map of old parameter keys to new parameter keys
|
||||
param_mapping = {
|
||||
'openwebui.api_key': 'openai.api_key',
|
||||
'openwebui.base_url': 'openai.base_url',
|
||||
'openwebui.model': 'openai.model',
|
||||
}
|
||||
|
||||
# For each parameter, check if it exists and migrate it if needed
|
||||
for old_key, new_key in param_mapping.items():
|
||||
# Check if the old parameter exists
|
||||
cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (old_key,))
|
||||
old_value = cr.fetchone()
|
||||
|
||||
if old_value and old_value[0]:
|
||||
# Check if the new parameter already exists
|
||||
cr.execute("SELECT value FROM ir_config_parameter WHERE key = %s", (new_key,))
|
||||
new_value = cr.fetchone()
|
||||
|
||||
if not new_value or not new_value[0]:
|
||||
# New parameter doesn't exist or is empty, set it to the old value
|
||||
_logger.info(f"Migrating parameter {old_key} to {new_key} with value {old_value[0]}")
|
||||
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (new_key, old_value[0]))
|
||||
|
||||
_logger.info("Migration of OpenWebUI configuration parameters completed")
|
||||
2
helpdesk_sale_order_ai/migrations/__init__.py
Normal file
2
helpdesk_sale_order_ai/migrations/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is intentionally left empty to make the directory a proper Python package
|
||||
|
|
@ -2,3 +2,10 @@
|
|||
|
||||
from . import helpdesk_ticket
|
||||
from . import helpdesk_team
|
||||
from . import sale_order
|
||||
# Using centralized openwebui.openwebui.client model from openwebui_connector
|
||||
from . import res_config_settings
|
||||
# Using centralized openwebui.prompt.template model from openwebui_connector
|
||||
|
||||
# Import views after all models are loaded
|
||||
from .. import views
|
||||
|
|
|
|||
115
helpdesk_sale_order_ai/models/ai_prompt_template.py
Normal file
115
helpdesk_sale_order_ai/models/ai_prompt_template.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class AIPromptTemplate(models.Model):
|
||||
_name = 'helpdesk.ai.prompt.template'
|
||||
_description = 'AI Prompt Template'
|
||||
_rec_name = 'name'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
is_default = fields.Boolean(
|
||||
string='Is Default',
|
||||
default=False,
|
||||
)
|
||||
content = fields.Text(
|
||||
string='Template Content',
|
||||
required=True,
|
||||
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _ensure_default_template(self):
|
||||
"""Ensure there's at least one default template"""
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("=== START: _ensure_default_template ====")
|
||||
|
||||
try:
|
||||
_logger.info("Searching for default template")
|
||||
default_template = self.search([('is_default', '=', True)], limit=1)
|
||||
_logger.info(f"Default template search result: {default_template}")
|
||||
|
||||
if not default_template:
|
||||
_logger.info("No default template found, creating one")
|
||||
# Create a default template if none exists
|
||||
default_content = """Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
|
||||
|
||||
Ticket Description: {description}
|
||||
Customer: {customer}
|
||||
|
||||
Please provide a list of products/services with quantities and descriptions in the following format:
|
||||
Product/Service Name | Quantity | Description
|
||||
"""
|
||||
_logger.info("Creating default template")
|
||||
new_template = self.create({
|
||||
'name': 'Default Template',
|
||||
'content': default_content,
|
||||
'is_default': True,
|
||||
})
|
||||
_logger.info(f"Created default template: {new_template}")
|
||||
else:
|
||||
_logger.info(f"Found existing default template: {default_template.name}")
|
||||
|
||||
_logger.info("=== END: _ensure_default_template ====")
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.error(f"ERROR in _ensure_default_template: {str(e)}")
|
||||
_logger.exception("Exception traceback:")
|
||||
raise
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to handle default templates"""
|
||||
records = super(AIPromptTemplate, self).create(vals_list)
|
||||
# If any new template is set as default, unset default flag on others
|
||||
default_templates = records.filtered(lambda r: r.is_default)
|
||||
if default_templates:
|
||||
self.search([('id', 'not in', default_templates.ids), ('is_default', '=', True)]).write({'is_default': False})
|
||||
return records
|
||||
|
||||
@api.model
|
||||
def get_default_template(self):
|
||||
"""Get the default template"""
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("=== START: get_default_template ====")
|
||||
|
||||
try:
|
||||
_logger.info("Searching for default template")
|
||||
default_template = self.search([('is_default', '=', True)], limit=1)
|
||||
_logger.info(f"Default template search result: {default_template}")
|
||||
|
||||
if not default_template:
|
||||
_logger.info("No default template found, ensuring one exists")
|
||||
self._ensure_default_template()
|
||||
default_template = self.search([('is_default', '=', True)], limit=1)
|
||||
_logger.info(f"Default template after ensure: {default_template}")
|
||||
|
||||
_logger.info("=== END: get_default_template ====")
|
||||
return default_template
|
||||
except Exception as e:
|
||||
_logger.error(f"ERROR in get_default_template: {str(e)}")
|
||||
_logger.exception("Exception traceback:")
|
||||
return self.browse()
|
||||
|
||||
def set_as_default(self):
|
||||
"""Set this template as the default"""
|
||||
if self:
|
||||
# Clear default flag on all other templates
|
||||
self.search([('id', '!=', self.id)]).write({'is_default': False})
|
||||
# Set this template as default
|
||||
self.write({'is_default': True})
|
||||
return True
|
||||
|
|
@ -7,19 +7,59 @@ class HelpdeskTeam(models.Model):
|
|||
|
||||
use_ai_sale_orders = fields.Boolean(
|
||||
string='Use AI for Sale Orders',
|
||||
help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions.',
|
||||
help='If checked, the system will use AI to automatically generate sale orders from ticket descriptions for this team. This overrides the global setting.',
|
||||
default=False,
|
||||
)
|
||||
|
||||
ai_prompt_template = fields.Text(
|
||||
ai_prompt_template_id = fields.Many2one(
|
||||
'openwebui.prompt.template',
|
||||
domain="[('template_type', '=', 'helpdesk')]",
|
||||
string='AI Prompt Template',
|
||||
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.',
|
||||
default="""Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
|
||||
|
||||
Ticket Description: {description}
|
||||
Customer: {customer}
|
||||
|
||||
Please provide a list of products/services with quantities and descriptions in the following format:
|
||||
Product/Service Name | Quantity | Description
|
||||
"""
|
||||
help='Template for the prompt sent to the AI. If empty, the global template will be used.',
|
||||
)
|
||||
|
||||
def _get_use_ai_sale_orders(self):
|
||||
"""Get whether to use AI sale orders, considering both team and global settings"""
|
||||
self.ensure_one()
|
||||
# If team has specific setting, use that
|
||||
if self.use_ai_sale_orders:
|
||||
return True
|
||||
|
||||
# Otherwise, check global setting
|
||||
param_value = self.env['ir.config_parameter'].sudo().get_param('helpdesk_sale_order_ai.use_ai_sale_orders', 'False')
|
||||
return param_value.lower() == 'true' if isinstance(param_value, str) else bool(param_value)
|
||||
|
||||
def _get_ai_prompt_template(self):
|
||||
"""Get the AI prompt template to use for this team"""
|
||||
self.ensure_one()
|
||||
|
||||
# Ensure template model is initialized
|
||||
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
|
||||
# If team has a specific template, use it
|
||||
if self.ai_prompt_template_id and self.ai_prompt_template_id.exists():
|
||||
return self.ai_prompt_template_id.content
|
||||
|
||||
# Otherwise, get the global default template
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
template = self.env['openwebui.prompt.template'].browse(int(template_id))
|
||||
if template.exists():
|
||||
return template.content
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Fallback to default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
return default_template.content if default_template else """
|
||||
Based on the following helpdesk ticket description, identify products and services that should be included in a sales order:
|
||||
|
||||
Ticket Description: {description}
|
||||
Customer: {customer}
|
||||
|
||||
Please provide a list of products/services with quantities and descriptions in the following format:
|
||||
Product/Service Name | Quantity | Description
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from odoo import models, fields, api, _
|
|||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -10,59 +11,185 @@ _logger = logging.getLogger(__name__)
|
|||
class HelpdeskTicket(models.Model):
|
||||
_inherit = 'helpdesk.ticket'
|
||||
|
||||
# Utiliser un champ calculé au lieu d'un champ simple avec onchange
|
||||
# Computed field to determine if team uses AI sale orders
|
||||
team_use_ai_sale_orders = fields.Boolean(
|
||||
string='Team Uses AI for Sale Orders',
|
||||
string='Team Uses AI Sale Orders',
|
||||
compute='_compute_team_use_ai_sale_orders',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.depends('team_id')
|
||||
def _compute_team_use_ai_sale_orders(self):
|
||||
for ticket in self:
|
||||
ticket.team_use_ai_sale_orders = False
|
||||
if ticket.team_id:
|
||||
# Vérifier si le champ existe sur l'équipe
|
||||
team = self.env['helpdesk.team'].sudo().browse(ticket.team_id.id)
|
||||
if hasattr(team, 'use_ai_sale_orders'):
|
||||
ticket.team_use_ai_sale_orders = team.use_ai_sale_orders
|
||||
|
||||
ai_generated_products = fields.Text(
|
||||
string='AI Generated Products',
|
||||
readonly=True,
|
||||
help='Products suggested by AI based on ticket description',
|
||||
)
|
||||
|
||||
@api.depends('team_id')
|
||||
def _compute_team_use_ai_sale_orders(self):
|
||||
for ticket in self:
|
||||
if ticket.team_id:
|
||||
ticket.team_use_ai_sale_orders = ticket.team_id._get_use_ai_sale_orders()
|
||||
else:
|
||||
ticket.team_use_ai_sale_orders = False
|
||||
|
||||
def action_convert_to_sale_order(self):
|
||||
"""Override to use AI for generating sale order if enabled"""
|
||||
"""Override to use AI if enabled"""
|
||||
self.ensure_one()
|
||||
|
||||
# Check if the team allows sale orders
|
||||
if not self.team_use_sale_orders:
|
||||
raise UserError(_("You cannot create a sale order from this ticket because your team does not allow it."))
|
||||
|
||||
# Vérifier directement sur l'équipe si l'IA est activée
|
||||
use_ai = False
|
||||
if self.team_id and hasattr(self.team_id, 'use_ai_sale_orders'):
|
||||
use_ai = self.team_id.use_ai_sale_orders
|
||||
|
||||
# If AI is enabled for this team, use it to generate the sale order
|
||||
if use_ai:
|
||||
# Check if AI sale orders are enabled for this team
|
||||
if self.team_use_ai_sale_orders:
|
||||
return self._ai_convert_to_sale_order()
|
||||
|
||||
# Otherwise, use the standard method from the parent module
|
||||
# Otherwise, use the standard method
|
||||
return super(HelpdeskTicket, self).action_convert_to_sale_order()
|
||||
|
||||
def _ai_convert_to_sale_order(self):
|
||||
"""Create a sale order using AI to suggest products based on ticket description"""
|
||||
self.ensure_one()
|
||||
|
||||
# Generate AI suggestions if not already done
|
||||
if not self.ai_generated_products:
|
||||
self._generate_ai_product_suggestions()
|
||||
_logger.info("Starting AI conversion to sale order for ticket %s", self.id)
|
||||
|
||||
# Create the sale order with AI-suggested products
|
||||
so_vals = self._generate_ai_so_values()
|
||||
sale_order = self.env['sale.order'].create([so_vals])
|
||||
# Always generate fresh AI suggestions
|
||||
_logger.info("Generating fresh AI suggestions for ticket %s", self.id)
|
||||
result = self._generate_ai_product_suggestions()
|
||||
_logger.info("AI suggestion generation result for ticket %s: %s", self.id, result)
|
||||
|
||||
# Get base values for sale order (partner, pricelist, etc.)
|
||||
partner_id = self.partner_id.id
|
||||
partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice']
|
||||
partner_shipping_id = self.partner_id.address_get(['delivery'])['delivery']
|
||||
|
||||
# Parse AI suggestions to get order lines and sale order fields
|
||||
ai_data = {'order_lines': [], 'sale_order_fields': {}}
|
||||
if self.ai_generated_products:
|
||||
_logger.info("AI suggestions found for ticket %s, parsing them now: %s", self.id, self.ai_generated_products[:200])
|
||||
ai_data = self._parse_ai_product_suggestions()
|
||||
_logger.info("Parsed AI data: %s", ai_data)
|
||||
|
||||
# Prepare sale order values
|
||||
so_values = {
|
||||
'partner_id': partner_id,
|
||||
'partner_invoice_id': partner_invoice_id,
|
||||
'partner_shipping_id': partner_shipping_id,
|
||||
'ticket_id': self.id,
|
||||
'origin': self.name,
|
||||
'note': self.description,
|
||||
}
|
||||
|
||||
# Add AI-extracted fields to sale order values if available
|
||||
if ai_data.get('sale_order_fields'):
|
||||
so_fields = ai_data['sale_order_fields']
|
||||
|
||||
# Client order reference (PO number)
|
||||
if so_fields.get('client_order_ref'):
|
||||
so_values['client_order_ref'] = so_fields['client_order_ref']
|
||||
_logger.info(f"Setting client_order_ref to: {so_fields['client_order_ref']}")
|
||||
|
||||
# Order date
|
||||
if so_fields.get('date_order'):
|
||||
try:
|
||||
# Validate date format
|
||||
from datetime import datetime
|
||||
date_order = datetime.strptime(so_fields['date_order'], '%Y-%m-%d')
|
||||
so_values['date_order'] = date_order
|
||||
_logger.info(f"Setting date_order to: {so_fields['date_order']}")
|
||||
except (ValueError, TypeError) as e:
|
||||
_logger.warning(f"Invalid date_order format: {so_fields['date_order']}, error: {e}")
|
||||
|
||||
# Commitment date (delivery date)
|
||||
if so_fields.get('commitment_date'):
|
||||
try:
|
||||
# Validate date format
|
||||
from datetime import datetime
|
||||
commitment_date = datetime.strptime(so_fields['commitment_date'], '%Y-%m-%d')
|
||||
so_values['commitment_date'] = commitment_date
|
||||
_logger.info(f"Setting commitment_date to: {so_fields['commitment_date']}")
|
||||
except (ValueError, TypeError) as e:
|
||||
_logger.warning(f"Invalid commitment_date format: {so_fields['commitment_date']}, error: {e}")
|
||||
|
||||
# Note (special instructions)
|
||||
if so_fields.get('note'):
|
||||
# Append to existing note if any
|
||||
existing_note = so_values.get('note', '')
|
||||
if existing_note:
|
||||
so_values['note'] = f"{existing_note}\n\n{so_fields['note']}"
|
||||
else:
|
||||
so_values['note'] = so_fields['note']
|
||||
_logger.info(f"Setting note to: {so_values['note'][:100]}...")
|
||||
|
||||
# Payment terms
|
||||
if so_fields.get('payment_term_id'):
|
||||
# Try to find matching payment term
|
||||
payment_term_name = so_fields['payment_term_id']
|
||||
payment_term = self.env['account.payment.term'].search(
|
||||
['|', ('name', '=', payment_term_name), ('name', 'ilike', payment_term_name)], limit=1)
|
||||
if payment_term:
|
||||
so_values['payment_term_id'] = payment_term.id
|
||||
_logger.info(f"Setting payment_term_id to: {payment_term.name} (ID: {payment_term.id})")
|
||||
else:
|
||||
_logger.warning(f"Payment term not found: {payment_term_name}")
|
||||
|
||||
# Create the sale order
|
||||
sale_order = self.env['sale.order'].create(so_values)
|
||||
_logger.info(f"Created sale order with ID {sale_order.id}")
|
||||
|
||||
# Add order lines to the sale order
|
||||
order_lines = ai_data.get('order_lines', [])
|
||||
_logger.info("Adding %d order lines to sale order %s", len(order_lines), sale_order.id)
|
||||
|
||||
for line in order_lines:
|
||||
# Each line is a tuple (0, 0, values_dict)
|
||||
# Extract the values dict
|
||||
line_values = line[2]
|
||||
_logger.info("Creating order line with values: %s", line_values)
|
||||
# Create a new order line that will trigger price computation
|
||||
# Include the price_unit from the parsed data if available
|
||||
initial_values = {
|
||||
'order_id': sale_order.id,
|
||||
'product_id': line_values.get('product_id'),
|
||||
'product_uom_qty': line_values.get('product_uom_qty'),
|
||||
'name': line_values.get('name'),
|
||||
}
|
||||
|
||||
# Get the price that was calculated in _create_product_order_line
|
||||
calculated_price = line_values.get('price_unit')
|
||||
if calculated_price is not None:
|
||||
_logger.info(f"Using pre-calculated price: {calculated_price} for product ID: {line_values.get('product_id')}")
|
||||
initial_values['price_unit'] = calculated_price
|
||||
|
||||
order_line = self.env['sale.order.line'].new(initial_values)
|
||||
|
||||
# Trigger standard Odoo onchange to compute prices
|
||||
try:
|
||||
# This is the main onchange that should set the price based on product and pricelist
|
||||
order_line._onchange_product_id()
|
||||
|
||||
# Log the computed price for debugging
|
||||
_logger.info(f"Standard Odoo price computation: {order_line.price_unit} for product {order_line.product_id.name}")
|
||||
|
||||
# If price is still 0 and product has a list price, use that as fallback
|
||||
if order_line.price_unit == 0 and order_line.product_id.list_price > 0:
|
||||
order_line.price_unit = order_line.product_id.list_price
|
||||
_logger.info(f"Price was 0, using product list price: {order_line.price_unit}")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in standard price computation: {str(e)}")
|
||||
# Continue with creation even if price computation fails
|
||||
|
||||
# Create a clean dict with only the necessary values
|
||||
order_line_values = {
|
||||
'order_id': sale_order.id,
|
||||
'product_id': order_line.product_id.id,
|
||||
'product_uom_qty': order_line.product_uom_qty,
|
||||
'name': order_line.name,
|
||||
'price_unit': order_line.price_unit,
|
||||
}
|
||||
|
||||
# Add product_uom if it exists
|
||||
if order_line.product_uom:
|
||||
order_line_values['product_uom'] = order_line.product_uom.id
|
||||
|
||||
# Create the actual order line with computed prices
|
||||
self.env['sale.order.line'].create(order_line_values)
|
||||
|
||||
# Link the sale order to the ticket
|
||||
self.write({
|
||||
|
|
@ -75,107 +202,491 @@ class HelpdeskTicket(models.Model):
|
|||
'name': _('Sale Order'),
|
||||
'res_model': 'sale.order',
|
||||
'res_id': sale_order.id,
|
||||
'view_mode': 'form',
|
||||
'context': {'create': False},
|
||||
'view_mode': 'form,list',
|
||||
'context': self.env.context,
|
||||
}
|
||||
|
||||
def _generate_ai_product_suggestions(self):
|
||||
"""Use AI to generate product suggestions based on ticket description"""
|
||||
"""Use AI to generate product suggestions based on ticket description, chatter messages and attachments"""
|
||||
self.ensure_one()
|
||||
|
||||
# Skip if no description
|
||||
if not self.description:
|
||||
_logger.info("Generating AI product suggestions for ticket %s", self.id)
|
||||
|
||||
# Get the ticket description
|
||||
description = self.description or ""
|
||||
|
||||
# If description is empty, try to use the name
|
||||
if not description.strip():
|
||||
description = self.name or ""
|
||||
|
||||
# Get chatter messages
|
||||
chatter_messages = ""
|
||||
if self.message_ids:
|
||||
for message in self.message_ids:
|
||||
if message.body and not message.is_internal:
|
||||
# Extract text from HTML
|
||||
body_text = re.sub(r'<[^>]+>', ' ', message.body)
|
||||
chatter_messages += f"Message from {message.author_id.name or 'Unknown'}: {body_text}\n\n"
|
||||
|
||||
# Get attachments
|
||||
attachments_info = ""
|
||||
attachment_contents = ""
|
||||
if self.message_ids:
|
||||
for message in self.message_ids:
|
||||
if message.attachment_ids:
|
||||
for attachment in message.attachment_ids:
|
||||
attachments_info += f"Attachment: {attachment.name} ({attachment.mimetype})\n"
|
||||
|
||||
# Extract text from PDF attachments
|
||||
if attachment.mimetype == 'application/pdf' and attachment.datas:
|
||||
try:
|
||||
import base64
|
||||
import io
|
||||
|
||||
# Try to use PyPDF2 if available
|
||||
try:
|
||||
from PyPDF2 import PdfReader
|
||||
|
||||
pdf_data = base64.b64decode(attachment.datas)
|
||||
pdf_file = io.BytesIO(pdf_data)
|
||||
pdf_reader = PdfReader(pdf_file)
|
||||
|
||||
pdf_text = ""
|
||||
for page_num in range(len(pdf_reader.pages)): # Process all pages
|
||||
page = pdf_reader.pages[page_num]
|
||||
pdf_text += page.extract_text() + "\n"
|
||||
|
||||
attachment_contents += f"Content from {attachment.name}:\n{pdf_text}\n\n" # Include full text
|
||||
except ImportError:
|
||||
_logger.warning("PyPDF2 not available, skipping PDF text extraction")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error extracting text from PDF: {str(e)}")
|
||||
|
||||
# If everything is empty, show error
|
||||
if not description.strip() and not chatter_messages.strip() and not attachment_contents.strip():
|
||||
_logger.error("No content available for AI analysis")
|
||||
return False
|
||||
|
||||
# Create the prompt for the AI
|
||||
prompt = f"""You are an expert sales assistant for a pneumatic automation company.
|
||||
Your task is to analyze the customer request and suggest appropriate products or services.
|
||||
|
||||
Customer Request:
|
||||
{description}
|
||||
|
||||
Chatter Messages:
|
||||
{chatter_messages}
|
||||
|
||||
Attachments Information:
|
||||
{attachments_info}
|
||||
|
||||
Attachment Contents:
|
||||
{attachment_contents}
|
||||
|
||||
Based on this information, please perform two tasks:
|
||||
|
||||
1. Map the following Odoo sale order fields from the information provided:
|
||||
- client_order_ref: Customer's reference/PO number
|
||||
- date_order: Order date (in YYYY-MM-DD format)
|
||||
- commitment_date: Delivery date (in YYYY-MM-DD format)
|
||||
- note: Any special instructions or notes
|
||||
- payment_term_id: Payment terms (e.g., "Net 30", "2% 10 Net 30")
|
||||
|
||||
2. Suggest products or services that would meet the customer's needs.
|
||||
|
||||
IMPORTANT: Your response MUST be in valid JSON format as shown below. Do not include any explanatory text outside the JSON structure.
|
||||
|
||||
```json
|
||||
{{
|
||||
"sale_order_fields": {{
|
||||
"client_order_ref": "Customer PO number",
|
||||
"date_order": "YYYY-MM-DD",
|
||||
"commitment_date": "YYYY-MM-DD",
|
||||
"note": "Special instructions",
|
||||
"payment_term_id": "Payment terms"
|
||||
}},
|
||||
"products": [
|
||||
{{ "name": "Product Name", "quantity": 2, "description": "Product description" }},
|
||||
{{ "name": "Another Product", "quantity": 1, "description": "Another description" }}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
Only include fields and products that are clearly identified from the provided information.
|
||||
If you're not sure about a field or product, leave it blank or don't include it.
|
||||
If you cannot identify any products, return an empty products array but still include any sale_order_fields you can identify.
|
||||
|
||||
Remember: Your entire response must be valid JSON wrapped in code blocks. No other text.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Prepare the prompt using the template from the team
|
||||
prompt = self.team_id.ai_prompt_template.format(
|
||||
description=self.description,
|
||||
customer=self.partner_id.name or 'Unknown',
|
||||
)
|
||||
# Get the OpenWebUI client
|
||||
client = self.env['openai.openwebui.client']
|
||||
|
||||
# Call the AI service (assuming an OpenAI connector module exists)
|
||||
ai_service = self.env['openai.service'].sudo()
|
||||
response = ai_service.generate_completion(prompt)
|
||||
# Create the messages for the AI
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
# Store the AI response
|
||||
self.ai_generated_products = response
|
||||
# Call the OpenWebUI API
|
||||
_logger.info("Calling OpenWebUI API for ticket %s", self.id)
|
||||
response = client.chat_completion(messages)
|
||||
|
||||
return True
|
||||
if response:
|
||||
_logger.info("Received AI response for ticket %s: %s", self.id, response[:100])
|
||||
|
||||
# Store the AI-generated products
|
||||
self.ai_generated_products = response
|
||||
|
||||
return True
|
||||
else:
|
||||
_logger.error("Empty response from OpenWebUI API for ticket %s", self.id)
|
||||
return False
|
||||
except Exception as e:
|
||||
_logger.error("Error generating AI product suggestions: %s", str(e))
|
||||
_logger.error("Error generating AI product suggestions for ticket %s: %s", self.id, str(e))
|
||||
import traceback
|
||||
_logger.error("Traceback: %s", traceback.format_exc())
|
||||
return False
|
||||
|
||||
# Note: This method is kept for compatibility but is no longer used
|
||||
# The _ai_convert_to_sale_order method now creates the sale order directly
|
||||
def _generate_ai_so_values(self):
|
||||
"""Generate sale order values with AI-suggested products"""
|
||||
# Start with the base SO values from the parent method
|
||||
so_vals = self._generate_so_values()
|
||||
|
||||
# Parse AI suggestions and add as order lines
|
||||
if self.ai_generated_products:
|
||||
order_lines = self._parse_ai_product_suggestions()
|
||||
if order_lines:
|
||||
so_vals['order_line'] = order_lines
|
||||
|
||||
return so_vals
|
||||
return self._generate_so_values()
|
||||
|
||||
def _parse_ai_product_suggestions(self):
|
||||
"""Parse the AI-generated product suggestions into sale order lines"""
|
||||
order_lines = []
|
||||
"""Parse the AI-generated product suggestions into sale order lines and fields"""
|
||||
result = {
|
||||
'order_lines': [],
|
||||
'sale_order_fields': {}
|
||||
}
|
||||
|
||||
if not self.ai_generated_products:
|
||||
return order_lines
|
||||
_logger.warning("No AI generated products found for ticket %s", self.id)
|
||||
return result['order_lines']
|
||||
|
||||
# Simple parsing of the AI response
|
||||
# Format expected: Product/Service Name | Quantity | Description
|
||||
lines = self.ai_generated_products.strip().split('\n')
|
||||
# Log the AI response for debugging
|
||||
_logger.info("Parsing AI product suggestions for ticket %s: %s", self.id, self.ai_generated_products[:300])
|
||||
|
||||
for line in lines:
|
||||
if '|' not in line:
|
||||
continue
|
||||
|
||||
parts = [part.strip() for part in line.split('|')]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
product_name = parts[0]
|
||||
quantity = 1.0
|
||||
description = ''
|
||||
# First, try to extract JSON from the response using multiple patterns
|
||||
# Pattern 1: Standard code block with json tag
|
||||
json_pattern1 = r'```(?:json)?\s*({[\s\S]*?})\s*```'
|
||||
# Pattern 2: Just find any JSON-like structure with sale_order_fields or products
|
||||
json_pattern2 = r'({[\s\S]*?"(?:sale_order_fields|products)"[\s\S]*?})'
|
||||
# Pattern 3: Find any JSON-like structure (most permissive)
|
||||
json_pattern3 = r'({\s*"[^"]+"\s*:.*})' # Any JSON object with at least one key
|
||||
|
||||
json_matches = re.findall(json_pattern1, self.ai_generated_products)
|
||||
|
||||
if not json_matches:
|
||||
_logger.info("No JSON found with pattern 1, trying pattern 2")
|
||||
json_matches = re.findall(json_pattern2, self.ai_generated_products)
|
||||
|
||||
# Try to parse quantity
|
||||
if len(parts) > 1:
|
||||
if not json_matches:
|
||||
_logger.info("No JSON found with pattern 2, trying pattern 3")
|
||||
json_matches = re.findall(json_pattern3, self.ai_generated_products)
|
||||
|
||||
if json_matches:
|
||||
# Try to parse the JSON
|
||||
try:
|
||||
# Clean up the JSON string before parsing
|
||||
json_str = json_matches[0]
|
||||
# Remove any trailing commas before closing brackets (common JSON error)
|
||||
json_str = re.sub(r',\s*([\]\}])', r'\1', json_str)
|
||||
|
||||
json_data = json.loads(json_str)
|
||||
_logger.info(f"Successfully parsed JSON data: {json_data}")
|
||||
|
||||
# Extract sale order fields
|
||||
if 'sale_order_fields' in json_data:
|
||||
result['sale_order_fields'] = json_data['sale_order_fields']
|
||||
_logger.info(f"Extracted sale order fields: {result['sale_order_fields']}")
|
||||
# Direct fields at root level (fallback)
|
||||
elif any(key in json_data for key in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']):
|
||||
so_fields = {}
|
||||
for field in ['client_order_ref', 'date_order', 'commitment_date', 'note', 'payment_term_id']:
|
||||
if field in json_data:
|
||||
so_fields[field] = json_data[field]
|
||||
result['sale_order_fields'] = so_fields
|
||||
_logger.info(f"Extracted sale order fields from root level: {result['sale_order_fields']}")
|
||||
|
||||
# Extract products - check multiple possible keys
|
||||
product_key = None
|
||||
for key in ['products', 'product_suggestions', 'order_lines', 'items']:
|
||||
if key in json_data and isinstance(json_data[key], list):
|
||||
product_key = key
|
||||
break
|
||||
|
||||
if product_key:
|
||||
for product in json_data[product_key]:
|
||||
if not isinstance(product, dict):
|
||||
continue
|
||||
|
||||
product_name = product.get('name')
|
||||
if not product_name:
|
||||
continue
|
||||
|
||||
quantity = product.get('quantity', 1.0)
|
||||
try:
|
||||
quantity = float(quantity)
|
||||
except (ValueError, TypeError):
|
||||
quantity = 1.0
|
||||
|
||||
description = product.get('description', '')
|
||||
|
||||
_logger.info(f"Processing product from JSON: {product_name}, qty={quantity}, desc={description}")
|
||||
|
||||
order_line = self._create_product_order_line(product_name, quantity, description)
|
||||
if order_line:
|
||||
result['order_lines'].append(order_line)
|
||||
|
||||
_logger.info(f"Parsed {len(result['order_lines'])} order lines from JSON")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
_logger.error(f"Failed to parse JSON: {e}")
|
||||
|
||||
# Try to extract just the sale order fields using regex as a last resort
|
||||
try:
|
||||
quantity = float(parts[1])
|
||||
except ValueError:
|
||||
quantity = 1.0
|
||||
# Look for client_order_ref pattern
|
||||
po_pattern = r'(?:client_order_ref|PO number|purchase order)[\s"]*[:=]\s*["]*([^"\n,}]+)'
|
||||
po_match = re.search(po_pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if po_match:
|
||||
result['sale_order_fields']['client_order_ref'] = po_match.group(1).strip()
|
||||
|
||||
# Look for dates
|
||||
date_pattern = r'(?:date_order|order date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
|
||||
date_match = re.search(date_pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if date_match:
|
||||
result['sale_order_fields']['date_order'] = date_match.group(1)
|
||||
|
||||
# Look for commitment date
|
||||
commit_pattern = r'(?:commitment_date|delivery date)[\s"]*[:=]\s*["]*([0-9]{4}-[0-9]{2}-[0-9]{2})'
|
||||
commit_match = re.search(commit_pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if commit_match:
|
||||
result['sale_order_fields']['commitment_date'] = commit_match.group(1)
|
||||
|
||||
# Look for payment terms
|
||||
payment_pattern = r'(?:payment_term_id|payment terms)[\s"]*[:=]\s*["]*([^"\n,}]+)'
|
||||
payment_match = re.search(payment_pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if payment_match:
|
||||
result['sale_order_fields']['payment_term_id'] = payment_match.group(1).strip()
|
||||
|
||||
if result['sale_order_fields']:
|
||||
_logger.info(f"Extracted sale order fields using regex: {result['sale_order_fields']}")
|
||||
except Exception as regex_error:
|
||||
_logger.error(f"Error in regex extraction fallback: {regex_error}")
|
||||
|
||||
# If JSON parsing failed, fall back to the old parsing methods
|
||||
_logger.info("Falling back to legacy parsing methods")
|
||||
|
||||
# Try to extract reference number using regex before falling back to line-by-line parsing
|
||||
ref_patterns = [
|
||||
r'(?:reference|ticket|po|purchase order)[\s\-]*(?:number|#)?[\s\-:]*([\d\-]+)',
|
||||
r'(?:client_order_ref|order ref)[\s"]*[:=]\s*["]*([^"\n,}]+)'
|
||||
]
|
||||
|
||||
for pattern in ref_patterns:
|
||||
ref_match = re.search(pattern, self.ai_generated_products, re.IGNORECASE)
|
||||
if ref_match:
|
||||
ref_number = ref_match.group(1).strip()
|
||||
_logger.info(f"Found reference number using regex: {ref_number}")
|
||||
result['sale_order_fields']['client_order_ref'] = ref_number
|
||||
break
|
||||
order_lines = []
|
||||
|
||||
# Try to parse the AI response in different formats
|
||||
# First, look for a table format with | separators
|
||||
table_pattern = r"([^|\n]+)\s*\|\s*(\d*\.?\d*)\s*\|\s*([^|\n]*)"
|
||||
table_matches = re.findall(table_pattern, self.ai_generated_products)
|
||||
|
||||
if table_matches:
|
||||
# Process table format
|
||||
_logger.info(f"Found table format with {len(table_matches)} matches")
|
||||
for match in table_matches:
|
||||
product_name = match[0].strip()
|
||||
if not product_name or product_name.lower() in ['product/service name', 'product', 'service', 'item']:
|
||||
continue
|
||||
|
||||
# Parse quantity
|
||||
quantity = 1.0
|
||||
if match[1].strip():
|
||||
try:
|
||||
quantity = float(match[1].strip())
|
||||
except ValueError:
|
||||
quantity = 1.0
|
||||
|
||||
# Get description
|
||||
description = match[2].strip() if match[2].strip() else product_name
|
||||
|
||||
# Add the order line
|
||||
order_line = self._create_product_order_line(product_name, quantity, description)
|
||||
if order_line:
|
||||
order_lines.append(order_line)
|
||||
else:
|
||||
# Try to parse line by line for products and quantities
|
||||
# Look for patterns like "2x Product Name" or "Product Name (qty: 3)" or "Product Name - 4 units"
|
||||
lines = self.ai_generated_products.strip().split('\n')
|
||||
_logger.info(f"Parsing line by line, found {len(lines)} lines")
|
||||
|
||||
# Get description if available
|
||||
if len(parts) > 2:
|
||||
description = parts[2]
|
||||
# Skip header lines and empty lines
|
||||
processed_lines = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
# Skip empty lines, headers, and other non-product lines
|
||||
if (not line or
|
||||
line.startswith('#') or
|
||||
line.lower().startswith('product') or
|
||||
line.lower() == 'format your response as' or
|
||||
line.lower() == 'for example:'):
|
||||
continue
|
||||
|
||||
# Remove bullet points and other common prefixes
|
||||
line = re.sub(r'^[-*\u2022]\s*', '', line)
|
||||
processed_lines.append(line)
|
||||
|
||||
# Search for matching product
|
||||
for line in processed_lines:
|
||||
_logger.info(f"Processing line: {line}")
|
||||
|
||||
# Try to extract quantity, product name, part number, and description
|
||||
# Format examples:
|
||||
# - 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency
|
||||
# - 1x Preventive Maintenance Service: Annual service package
|
||||
# - 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI
|
||||
|
||||
# Pattern for the format specified in the prompt template
|
||||
detailed_pattern = r"(\d+)x\s+([^:]+?)(?:\s+([A-Z0-9][A-Z0-9-]+))?\s*:?\s*(.*)"
|
||||
match = re.search(detailed_pattern, line, re.IGNORECASE)
|
||||
|
||||
if match:
|
||||
quantity = float(match.group(1))
|
||||
product_name = match.group(2).strip()
|
||||
part_number = match.group(3) if match.group(3) else ''
|
||||
specs = match.group(4).strip() if match.group(4) else ''
|
||||
|
||||
# Combine part number with product name if available
|
||||
if part_number:
|
||||
full_product_name = f"{product_name} {part_number}"
|
||||
else:
|
||||
full_product_name = product_name
|
||||
|
||||
# Use specifications as description if available
|
||||
description = specs if specs else product_name
|
||||
|
||||
_logger.info(f"Matched detailed pattern: qty={quantity}, product={full_product_name}, desc={description}")
|
||||
|
||||
order_line = self._create_product_order_line(full_product_name, quantity, description)
|
||||
if order_line:
|
||||
order_lines.append(order_line)
|
||||
continue
|
||||
|
||||
# Try other common patterns if the detailed pattern didn't match
|
||||
qty_patterns = [
|
||||
r"(\d+(?:\.\d+)?)\s*x\s*([^\d\n]+)", # "2x Product Name" or "2.5x Product Name"
|
||||
r"([^\d\n]+)\s*\(\s*qty\s*:\s*(\d+(?:\.\d+)?)\s*\)", # "Product Name (qty: 3)"
|
||||
r"([^\d\n]+)\s*-\s*(\d+(?:\.\d+)?)\s*units?", # "Product Name - 4 units"
|
||||
r"([^\d\n]+)\s*:\s*(\d+(?:\.\d+)?)", # "Product Name: 2"
|
||||
r"quantity\s*:\s*(\d+(?:\.\d+)?)\s*,?\s*([^,]+)", # "Quantity: 2, Product Name"
|
||||
]
|
||||
|
||||
product_name = None
|
||||
quantity = 1.0
|
||||
description = ""
|
||||
|
||||
for pattern in qty_patterns:
|
||||
match = re.search(pattern, line, re.IGNORECASE)
|
||||
if match:
|
||||
if pattern == qty_patterns[0]: # "2x Product Name"
|
||||
try:
|
||||
quantity = float(match.group(1))
|
||||
product_name = match.group(2).strip()
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
else: # Other patterns
|
||||
try:
|
||||
product_name = match.group(1).strip()
|
||||
quantity = float(match.group(2))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# Try to extract description after the product name
|
||||
desc_match = re.search(r"[^:]+:(.+)$", line)
|
||||
if desc_match:
|
||||
description = desc_match.group(1).strip()
|
||||
|
||||
_logger.info(f"Matched pattern {pattern}: qty={quantity}, product={product_name}, desc={description}")
|
||||
break
|
||||
|
||||
# If no pattern matched, use the whole line as product name
|
||||
if not product_name:
|
||||
# Check if there's a colon that might separate product name from description
|
||||
if ':' in line:
|
||||
parts = line.split(':', 1)
|
||||
product_name = parts[0].strip()
|
||||
description = parts[1].strip() if len(parts) > 1 else ''
|
||||
else:
|
||||
product_name = line
|
||||
description = ''
|
||||
|
||||
_logger.info(f"No pattern match, using line as product: {product_name}, desc={description}")
|
||||
|
||||
# Add the order line
|
||||
order_line = self._create_product_order_line(product_name, quantity, description)
|
||||
if order_line:
|
||||
order_lines.append(order_line)
|
||||
|
||||
result['order_lines'] = order_lines
|
||||
_logger.info(f"Parsed {len(order_lines)} order lines from AI suggestions")
|
||||
return result
|
||||
|
||||
def _create_product_order_line(self, product_name, quantity, description=""):
|
||||
"""Create a sale order line for a product"""
|
||||
if not product_name:
|
||||
return False
|
||||
|
||||
# Search for matching product - try exact match first
|
||||
product = self.env['product.product'].search([
|
||||
('name', '=', product_name),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1)
|
||||
|
||||
# If no exact match, try partial match
|
||||
if not product:
|
||||
product = self.env['product.product'].search([
|
||||
('name', 'ilike', product_name),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1)
|
||||
|
||||
if not product:
|
||||
# If no product found, create a service product
|
||||
product = self.env['product.product'].create({
|
||||
'name': product_name,
|
||||
'type': 'service',
|
||||
'sale_ok': True,
|
||||
'purchase_ok': False,
|
||||
'list_price': 0.0,
|
||||
})
|
||||
|
||||
# Create order line
|
||||
order_line = (0, 0, {
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': quantity,
|
||||
'name': description or product.name,
|
||||
})
|
||||
|
||||
order_lines.append(order_line)
|
||||
|
||||
return order_lines
|
||||
# If still no product found, try matching by default_code (SKU/part number)
|
||||
if not product and any(c.isdigit() for c in product_name): # Check if product name contains numbers (likely a part number)
|
||||
# Extract potential part numbers
|
||||
part_numbers = re.findall(r'[A-Z0-9][A-Z0-9-]+', product_name)
|
||||
for part in part_numbers:
|
||||
product = self.env['product.product'].search([
|
||||
('default_code', '=', part),
|
||||
('sale_ok', '=', True)
|
||||
], limit=1)
|
||||
if product:
|
||||
break
|
||||
|
||||
# If no product found, log it and return False
|
||||
if not product:
|
||||
_logger.info(f"No matching product found for: {product_name}")
|
||||
return False
|
||||
|
||||
# Create order line with price information
|
||||
line_values = {
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': quantity,
|
||||
'name': description or product.name,
|
||||
}
|
||||
|
||||
# We don't need to set the price here - Odoo will handle this automatically
|
||||
# when the sale order line is created with the product
|
||||
# Just log the product's list price for debugging
|
||||
_logger.info(f"Product {product.name} (ID: {product.id}) has list_price: {product.list_price}")
|
||||
|
||||
# We intentionally don't set price_unit here to let Odoo's standard mechanisms handle it
|
||||
|
||||
return (0, 0, line_values)
|
||||
|
|
|
|||
111
helpdesk_sale_order_ai/models/res_config_settings.py
Normal file
111
helpdesk_sale_order_ai/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# We no longer need to define the fields here as they are now defined in the openai_connector module
|
||||
# This ensures that the settings are always in sync between the two modules
|
||||
|
||||
# Override the model field to use our selection method
|
||||
openai_model = fields.Selection(
|
||||
selection='_get_openwebui_models',
|
||||
string='AI Model',
|
||||
help='Model to use for AI API calls',
|
||||
default='anthropic.claude-3-7-sonnet-latest',
|
||||
config_parameter='openai.model',
|
||||
)
|
||||
|
||||
# Add a field to select the prompt template directly
|
||||
# In Odoo 18, we need to ensure this field is properly defined
|
||||
helpdesk_ai_prompt_template_id = fields.Many2one(
|
||||
'openwebui.prompt.template',
|
||||
domain="[('template_type', '=', 'helpdesk')]",
|
||||
string='Default AI Prompt Template',
|
||||
help='Default template for the prompt sent to the AI.',
|
||||
ondelete='set null', # This helps avoid constraint errors
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
"""Get values for the settings form"""
|
||||
res = super(ResConfigSettings, self).get_values()
|
||||
|
||||
# Get the template ID from the system parameter
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
res['helpdesk_ai_prompt_template_id'] = template_id_int
|
||||
except (ValueError, TypeError):
|
||||
_logger.error(f"Invalid template ID in system parameter: {template_id}")
|
||||
|
||||
return res
|
||||
|
||||
def set_values(self):
|
||||
"""Set values from the settings form"""
|
||||
super(ResConfigSettings, self).set_values()
|
||||
|
||||
# Save the template ID to the system parameter
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
if self.helpdesk_ai_prompt_template_id:
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.helpdesk_ai_prompt_template_id.id))
|
||||
else:
|
||||
# If no template is selected, ensure there's a default one
|
||||
self._set_default_values()
|
||||
|
||||
@api.model
|
||||
def _get_openwebui_models(self):
|
||||
"""Get available models from OpenWebUI API"""
|
||||
try:
|
||||
client = self.env['openwebui.client']
|
||||
models = client.get_available_models()
|
||||
return models
|
||||
except Exception as e:
|
||||
_logger.error(f"Error fetching OpenWebUI models: {e}")
|
||||
# Return default model on error
|
||||
default_model = 'anthropic.claude-3-7-sonnet-latest'
|
||||
return [(default_model, default_model)]
|
||||
|
||||
@api.model
|
||||
def _set_default_values(self):
|
||||
"""Set default values for configuration parameters"""
|
||||
# Ensure there's a default template
|
||||
template_model = self.env['openwebui.prompt.template']
|
||||
template_model._ensure_default_template()
|
||||
default_template = template_model.get_default_template()
|
||||
|
||||
# Set the default template ID in config parameters if not set
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
|
||||
if not default_template_id and default_template:
|
||||
# Store as string to ensure compatibility with the OpenAI connector module
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
|
||||
@api.model
|
||||
def get_prompt_template(self):
|
||||
"""Get the prompt template content from the selected template or default"""
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
# Make sure the template model exists first
|
||||
self.env['openwebui.prompt.template']._ensure_default_template('helpdesk')
|
||||
# The template_id is already an integer in the database now
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
template = self.env['openwebui.prompt.template'].browse(template_id_int)
|
||||
if template.exists():
|
||||
return template.content
|
||||
except (ValueError, TypeError) as e:
|
||||
_logger.error(f"Error retrieving prompt template: {e}")
|
||||
pass
|
||||
|
||||
# Fallback to default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
return default_template.content if default_template else ""
|
||||
29
helpdesk_sale_order_ai/models/sale_order.py
Normal file
29
helpdesk_sale_order_ai/models/sale_order.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
# Add missing_product_count field to avoid view errors
|
||||
# This is needed because some views expect this field
|
||||
missing_product_count = fields.Integer(
|
||||
string='Missing Products Count',
|
||||
default=0,
|
||||
help='Number of products that could not be found in the database'
|
||||
)
|
||||
|
||||
# Add has_missing_products field to avoid view errors
|
||||
has_missing_products = fields.Boolean(
|
||||
string='Has Missing Products',
|
||||
default=False,
|
||||
help='Whether this sale order has products that could not be found in the database'
|
||||
)
|
||||
|
||||
# Add ticket_id field to link sale orders to helpdesk tickets
|
||||
ticket_id = fields.Many2one(
|
||||
'helpdesk.ticket',
|
||||
string='Helpdesk Ticket',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='Helpdesk ticket from which this sale order was created'
|
||||
)
|
||||
3
helpdesk_sale_order_ai/security/ir.model.access.csv
Normal file
3
helpdesk_sale_order_ai/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_openwebui_prompt_template_helpdesk_user,openwebui.prompt.template.helpdesk.user,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_user,1,0,0,0
|
||||
access_openwebui_prompt_template_helpdesk_manager,openwebui.prompt.template.helpdesk.manager,openwebui_connector.model_openwebui_prompt_template,helpdesk.group_helpdesk_manager,1,1,1,1
|
||||
|
4
helpdesk_sale_order_ai/views/__init__.py
Normal file
4
helpdesk_sale_order_ai/views/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# These imports are handled differently in Odoo 18
|
||||
# XML files are loaded automatically by the manifest
|
||||
102
helpdesk_sale_order_ai/views/ai_prompt_template_views.xml
Normal file
102
helpdesk_sale_order_ai/views/ai_prompt_template_views.xml
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- AI Prompt Template Form View -->
|
||||
<record id="view_helpdesk_ai_prompt_template_form" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.form</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Prompt Template">
|
||||
<field name="template_type" invisible="1"/>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="set_as_default" type="object"
|
||||
class="oe_stat_button" icon="fa-star"
|
||||
invisible="is_default">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Set as Default</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="set_as_default" type="object"
|
||||
class="oe_stat_button" icon="fa-star" disabled="1"
|
||||
invisible="not is_default">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Default Template</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Template Name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="sequence" groups="base.group_no_one"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="is_default" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Template Content" name="template_content">
|
||||
<div class="text-muted mb-3">
|
||||
Use placeholders like {description}, {customer}, etc. to include ticket information in the prompt.
|
||||
</div>
|
||||
<field name="content" widget="text" class="oe_edit_only" style="min-height: 300px;"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template List View -->
|
||||
<record id="view_helpdesk_ai_prompt_template_list" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.list</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="template_type" invisible="1"/>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="is_default" string="Default"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template Search View -->
|
||||
<record id="view_helpdesk_ai_prompt_template_search" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.search</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Prompt Templates">
|
||||
<field name="template_type" invisible="1"/>
|
||||
<field name="name"/>
|
||||
<filter string="Default" name="default" domain="[('is_default', '=', True)]"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template Action -->
|
||||
<record id="action_helpdesk_ai_prompt_template" model="ir.actions.act_window">
|
||||
<field name="name">AI Prompt Templates</field>
|
||||
<field name="res_model">openwebui.prompt.template</field>
|
||||
<field name="domain">[('template_type', '=', 'helpdesk')]</field>
|
||||
<field name="context">{'default_template_type': 'helpdesk'}</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new AI prompt template
|
||||
</p>
|
||||
<p>
|
||||
Define templates for AI prompts used in helpdesk tickets.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_helpdesk_ai_prompt_template"
|
||||
name="AI Prompt Templates"
|
||||
parent="helpdesk.helpdesk_menu_config"
|
||||
action="action_helpdesk_ai_prompt_template"
|
||||
sequence="50"/>
|
||||
</odoo>
|
||||
|
|
@ -5,9 +5,9 @@
|
|||
<field name="model">helpdesk.team</field>
|
||||
<field name="inherit_id" ref="helpdesk.helpdesk_team_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='team_use_sale_orders']" position="after">
|
||||
<field name="use_ai_sale_orders" attrs="{'invisible': [('team_use_sale_orders', '=', False)]}"/>
|
||||
<field name="ai_prompt_template" attrs="{'invisible': [('use_ai_sale_orders', '=', False)]}" widget="text_field"/>
|
||||
<xpath expr="//field[@name='use_sale_orders']" position="after">
|
||||
<field name="use_ai_sale_orders" invisible="not use_sale_orders"/>
|
||||
<field name="ai_prompt_template_id" invisible="not use_ai_sale_orders"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
|
|
|||
21
helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml
Normal file
21
helpdesk_sale_order_ai/views/helpdesk_ticket_views.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="helpdesk_ticket_form_view_inherit_ai" model="ir.ui.view">
|
||||
<field name="name">helpdesk.ticket.form.view.inherit.ai</field>
|
||||
<field name="model">helpdesk.ticket</field>
|
||||
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='team_id']" position="after">
|
||||
<field name="team_use_sale_orders" invisible="1"/>
|
||||
<field name="team_use_ai_sale_orders" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="AI Suggestions" invisible="not team_use_ai_sale_orders">
|
||||
<group>
|
||||
<field name="ai_generated_products" widget="text" readonly="1"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
16
helpdesk_sale_order_ai/views/res_config_settings_views.xml
Normal file
16
helpdesk_sale_order_ai/views/res_config_settings_views.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- We need to make the field available in the form without adding UI elements -->
|
||||
<record id="res_config_settings_view_form_inherit_helpdesk_sale_order_ai" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.helpdesk.sale.order.ai</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- In Odoo 18, we add fields to the form directly -->
|
||||
<form position="inside">
|
||||
<!-- Hidden field that will be used by the settings logic -->
|
||||
<field name="helpdesk_ai_prompt_template_id" invisible="1"/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
3
helpdesk_sale_order_ai/wizards/__init__.py
Normal file
3
helpdesk_sale_order_ai/wizards/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import openwebui_config_wizard
|
||||
3
openwebui_connector/__init__.py
Normal file
3
openwebui_connector/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
30
openwebui_connector/__manifest__.py
Normal file
30
openwebui_connector/__manifest__.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'OpenWebUI Connector',
|
||||
'version': '18.0.1.0.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Connect to OpenWebUI and other AI services',
|
||||
'description': """
|
||||
OpenWebUI Connector
|
||||
===============
|
||||
This module provides integration with OpenWebUI and other AI services.
|
||||
It allows other modules to use AI capabilities through a standardized interface.
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'maintainer': 'it@bemade.org',
|
||||
'depends': ['base'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/ai_prompt_template_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/ai_prompt_template_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
'external_dependencies': {
|
||||
'python': ['requests'],
|
||||
},
|
||||
}
|
||||
49
openwebui_connector/data/ai_prompt_template_data.xml
Normal file
49
openwebui_connector/data/ai_prompt_template_data.xml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Default Helpdesk AI Prompt Template -->
|
||||
<record id="default_helpdesk_prompt_template" model="openwebui.prompt.template">
|
||||
<field name="name">Default Helpdesk Template</field>
|
||||
<field name="template_type">helpdesk</field>
|
||||
<field name="is_default" eval="True"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="content">You are a helpful AI assistant for Bemade. Your task is to analyze a helpdesk ticket and suggest products or services that should be included in a sales order.
|
||||
|
||||
TICKET INFORMATION:
|
||||
Customer: {customer}
|
||||
Subject: {subject}
|
||||
Description: {description}
|
||||
|
||||
Based on the ticket information above, please suggest products or services that should be included in a sales order. Format your response as a list of products with quantities. If possible, match to existing products in our catalog.
|
||||
|
||||
For each product suggestion, include:
|
||||
1. Product name
|
||||
2. Quantity
|
||||
3. Brief justification for including this item
|
||||
|
||||
Format your response as follows:
|
||||
```
|
||||
PRODUCT SUGGESTIONS:
|
||||
- 2x Product Name: Justification
|
||||
- 1x Another Product: Justification
|
||||
```
|
||||
|
||||
If you need more information to make accurate suggestions, please indicate what information is missing.</field>
|
||||
</record>
|
||||
|
||||
<!-- Default General AI Prompt Template -->
|
||||
<record id="default_general_prompt_template" model="openwebui.prompt.template">
|
||||
<field name="name">Default General Template</field>
|
||||
<field name="template_type">general</field>
|
||||
<field name="is_default" eval="True"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="content">You are a helpful AI assistant for Bemade. Please analyze the following content and provide a concise and helpful response:
|
||||
|
||||
{content}
|
||||
|
||||
Please provide a clear and professional response.</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
2
openwebui_connector/migrations/1.0/__init__.py
Normal file
2
openwebui_connector/migrations/1.0/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is intentionally left empty to make the directory a proper Python package
|
||||
56
openwebui_connector/migrations/1.0/post-migration.py
Normal file
56
openwebui_connector/migrations/1.0/post-migration.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run after the module update
|
||||
# It will ensure the system parameters are properly set up
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Ensure system parameters are properly set up after migration
|
||||
"""
|
||||
# Set default values for system parameters
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('openwebui.base_url', 'https://ai.bemade.org/api', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('openwebui.model', 'anthropic.claude-3-7-sonnet-latest', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
|
||||
'Post-migration: Set default system parameters',
|
||||
'/addons/openwebui_connector/migrations/1.0/post-migration.py', 30, 'migrate')
|
||||
""")
|
||||
|
||||
# Check if helpdesk_sale_order_ai module is installed
|
||||
cr.execute("""
|
||||
SELECT id FROM ir_module_module
|
||||
WHERE name = 'helpdesk_sale_order_ai'
|
||||
AND state = 'installed'
|
||||
""")
|
||||
|
||||
if cr.fetchone():
|
||||
_logger.info("helpdesk_sale_order_ai module is installed, ensuring parameters are set")
|
||||
|
||||
# Ensure the helpdesk AI parameter exists
|
||||
cr.execute("""
|
||||
INSERT INTO ir_config_parameter (key, value, create_date, write_date)
|
||||
VALUES ('helpdesk_sale_order_ai.use_ai_sale_orders', 'False', now(), now())
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
|
||||
'Post-migration: Set default helpdesk AI parameters',
|
||||
'/addons/openwebui_connector/migrations/1.0/post-migration.py', 50, 'migrate')
|
||||
""")
|
||||
66
openwebui_connector/migrations/1.0/pre-migration.py
Normal file
66
openwebui_connector/migrations/1.0/pre-migration.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run before the module update
|
||||
# It will handle database constraints and column issues for Odoo 18 compatibility
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Handle database constraints and column issues for Odoo 18 compatibility
|
||||
"""
|
||||
# 1. Check and drop any foreign key constraints on helpdesk_ai_prompt_template_id
|
||||
cr.execute("""
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_name = 'res_config_settings'
|
||||
AND ccu.column_name = 'helpdesk_ai_prompt_template_id'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
""")
|
||||
|
||||
constraints = cr.fetchall()
|
||||
for constraint in constraints:
|
||||
constraint_name = constraint[0]
|
||||
# Drop the constraint
|
||||
cr.execute(f"""
|
||||
ALTER TABLE res_config_settings
|
||||
DROP CONSTRAINT IF EXISTS {constraint_name}
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
|
||||
'Migration: Dropped foreign key constraint: %s',
|
||||
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 30, 'migrate')
|
||||
""", (constraint_name,))
|
||||
|
||||
# 2. Check if the column exists and drop it if needed
|
||||
cr.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'res_config_settings'
|
||||
AND column_name = 'helpdesk_ai_prompt_template_id'
|
||||
""")
|
||||
|
||||
if cr.fetchone():
|
||||
# We need to drop the column to avoid conflicts with the new field
|
||||
try:
|
||||
cr.execute("""
|
||||
ALTER TABLE res_config_settings
|
||||
DROP COLUMN IF EXISTS helpdesk_ai_prompt_template_id
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'info',
|
||||
'Migration: Dropped column helpdesk_ai_prompt_template_id',
|
||||
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 50, 'migrate')
|
||||
""")
|
||||
except Exception as e:
|
||||
# Log the error
|
||||
cr.execute("""
|
||||
INSERT INTO ir_logging(create_date, create_uid, name, type, dbname, level, message, path, line, func)
|
||||
VALUES (now(), 1, 'openwebui_connector', 'server', current_database(), 'error',
|
||||
'Migration error: %s',
|
||||
'/addons/openwebui_connector/migrations/1.0/pre-migration.py', 60, 'migrate')
|
||||
""", (str(e),))
|
||||
0
openwebui_connector/migrations/18.0.1.0.0/__init__.py
Normal file
0
openwebui_connector/migrations/18.0.1.0.0/__init__.py
Normal file
74
openwebui_connector/migrations/18.0.1.0.0/post-migration.py
Normal file
74
openwebui_connector/migrations/18.0.1.0.0/post-migration.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run after the module update
|
||||
# It will migrate data from openai_prompt_template to openwebui_prompt_template
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Migrate data from openai_prompt_template to openwebui_prompt_template
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
|
||||
_logger.info("Starting migration of prompt templates from openai to openwebui")
|
||||
|
||||
# Check if the old table exists
|
||||
cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
|
||||
if not cr.fetchone()[0]:
|
||||
_logger.info("No openai_prompt_template table found, skipping migration")
|
||||
return
|
||||
|
||||
# Check if there's data to migrate
|
||||
cr.execute("SELECT COUNT(*) FROM openai_prompt_template")
|
||||
count = cr.fetchone()[0]
|
||||
_logger.info(f"Found {count} records to migrate from openai_prompt_template")
|
||||
|
||||
if count > 0:
|
||||
# Copy data from old table to new table
|
||||
cr.execute("""
|
||||
INSERT INTO openwebui_prompt_template (
|
||||
id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
|
||||
)
|
||||
SELECT
|
||||
id, name, content, is_default, module, create_uid, create_date, write_uid, write_date
|
||||
FROM
|
||||
openai_prompt_template
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
content = EXCLUDED.content,
|
||||
is_default = EXCLUDED.is_default,
|
||||
module = EXCLUDED.module,
|
||||
write_uid = EXCLUDED.write_uid,
|
||||
write_date = EXCLUDED.write_date
|
||||
""")
|
||||
|
||||
# Update sequence if needed
|
||||
cr.execute("""
|
||||
SELECT MAX(id) FROM openwebui_prompt_template
|
||||
""")
|
||||
max_id = cr.fetchone()[0] or 0
|
||||
if max_id > 0:
|
||||
cr.execute(f"""
|
||||
SELECT setval('openwebui_prompt_template_id_seq', {max_id})
|
||||
""")
|
||||
|
||||
# Log the migration
|
||||
_logger.info(f"Successfully migrated {count} records from openai_prompt_template to openwebui_prompt_template")
|
||||
|
||||
# Update any references in res_config_settings
|
||||
cr.execute("""
|
||||
UPDATE ir_config_parameter
|
||||
SET key = REPLACE(key, 'openai.', 'openwebui.')
|
||||
WHERE key LIKE 'openai.%'
|
||||
""")
|
||||
|
||||
# Update any references to prompt template IDs in system parameters
|
||||
cr.execute("""
|
||||
UPDATE ir_config_parameter
|
||||
SET key = REPLACE(key, 'openai_prompt_template_id', 'openwebui_prompt_template_id')
|
||||
WHERE key LIKE '%openai_prompt_template_id%'
|
||||
""")
|
||||
|
||||
_logger.info("Updated system parameters to use openwebui prefix")
|
||||
54
openwebui_connector/migrations/18.0.1.0.0/pre-migration.py
Normal file
54
openwebui_connector/migrations/18.0.1.0.0/pre-migration.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This migration script will run before the module update
|
||||
# It will handle database constraints and prepare for the migration
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate(cr, version):
|
||||
"""
|
||||
Handle database constraints and prepare for migration
|
||||
"""
|
||||
if not version:
|
||||
return
|
||||
|
||||
_logger.info("Starting pre-migration for openwebui_connector")
|
||||
|
||||
# 1. Drop foreign key constraints on ai_prompt_template_id to avoid conflicts
|
||||
cr.execute("""
|
||||
SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_name = 'res_config_settings'
|
||||
AND ccu.column_name = 'ai_prompt_template_id'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
""")
|
||||
|
||||
constraints = cr.fetchall()
|
||||
for constraint in constraints:
|
||||
constraint_name = constraint[0]
|
||||
# Drop the constraint
|
||||
cr.execute(f"""
|
||||
ALTER TABLE res_config_settings
|
||||
DROP CONSTRAINT IF EXISTS {constraint_name}
|
||||
""")
|
||||
_logger.info(f"Dropped foreign key constraint: {constraint_name}")
|
||||
|
||||
# 2. Update system parameters to use new naming
|
||||
cr.execute("""
|
||||
UPDATE ir_config_parameter
|
||||
SET key = REPLACE(key, 'openai.', 'openwebui.')
|
||||
WHERE key LIKE 'openai.%'
|
||||
""")
|
||||
|
||||
# 3. Check if the old openai_prompt_template table exists
|
||||
cr.execute("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'openai_prompt_template')")
|
||||
if cr.fetchone()[0]:
|
||||
# Create a backup of the data if needed
|
||||
cr.execute("""
|
||||
CREATE TABLE IF NOT EXISTS openai_prompt_template_backup AS
|
||||
SELECT * FROM openai_prompt_template
|
||||
""")
|
||||
_logger.info("Created backup of openai_prompt_template data")
|
||||
|
||||
_logger.info("Pre-migration completed successfully")
|
||||
2
openwebui_connector/migrations/__init__.py
Normal file
2
openwebui_connector/migrations/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is intentionally left empty to make the directory a proper Python package
|
||||
5
openwebui_connector/models/__init__.py
Normal file
5
openwebui_connector/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import openwebui_client
|
||||
from . import ai_prompt_template
|
||||
from . import res_config_settings
|
||||
187
openwebui_connector/models/ai_prompt_template.py
Normal file
187
openwebui_connector/models/ai_prompt_template.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class AIPromptTemplate(models.Model):
|
||||
_name = 'openwebui.prompt.template'
|
||||
_description = 'AI Prompt Template'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
content = fields.Text(
|
||||
string='Template Content',
|
||||
required=True,
|
||||
help='Template for the prompt sent to the AI. Use placeholders like {description}, {customer}, etc.'
|
||||
)
|
||||
template_type = fields.Selection([
|
||||
('helpdesk', 'Helpdesk'),
|
||||
('general', 'General'),
|
||||
], string='Template Type', default='helpdesk', required=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
is_default = fields.Boolean(string='Default Template', default=False)
|
||||
|
||||
@api.model
|
||||
def _ensure_default_template(self, template_type='helpdesk'):
|
||||
"""Ensure that at least one default template exists for the given type"""
|
||||
_logger.info(f"Ensuring default template exists for type: {template_type}")
|
||||
try:
|
||||
default_template = self.search([('template_type', '=', template_type), ('is_default', '=', True)], limit=1)
|
||||
_logger.info(f"Found existing default template: {bool(default_template)}")
|
||||
|
||||
if not default_template:
|
||||
_logger.info("No default template found, creating one")
|
||||
# Create a default template
|
||||
default_content = self._get_default_template_content(template_type)
|
||||
default_name = f"Default {template_type.capitalize()} Template"
|
||||
|
||||
try:
|
||||
_logger.info(f"Creating default template with name: {default_name}")
|
||||
default_template = self.create({
|
||||
'name': default_name,
|
||||
'template_type': template_type,
|
||||
'content': default_content,
|
||||
'is_default': True,
|
||||
})
|
||||
_logger.info(f"Default template created with ID: {default_template.id}")
|
||||
|
||||
# Set the default template ID in system parameters
|
||||
if template_type == 'helpdesk':
|
||||
_logger.info("Setting default template ID in system parameters")
|
||||
param_name = 'helpdesk_sale_order_ai.default_prompt_template_id'
|
||||
param_value = str(default_template.id)
|
||||
_logger.info(f"Setting parameter {param_name} = {param_value}")
|
||||
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
param_name, param_value
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(f"Error creating default template: {e}", exc_info=True)
|
||||
_logger.error(f"Template data: name={default_name}, type={template_type}, content_length={len(default_content) if default_content else 0}")
|
||||
|
||||
return default_template
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error in _ensure_default_template: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _get_default_template_content(self, template_type):
|
||||
"""Get the default content for a template type"""
|
||||
if template_type == 'helpdesk':
|
||||
return """Based on the following helpdesk ticket information, create a complete sales order that accurately reflects the client's requirements.
|
||||
|
||||
Ticket Information: {description}
|
||||
Customer: {customer}
|
||||
Subject: {subject}
|
||||
|
||||
Analyze all information including ticket description, chatter messages, and attachments to create a comprehensive sales order that includes ALL items the client has requested.
|
||||
|
||||
Please provide a detailed list of products and services with exact quantities, specifications, and any relevant details mentioned by the client. Include part numbers when available.
|
||||
|
||||
Format your response as follows:
|
||||
- [Quantity]x [Product Name] [Part Number if available]: [Specifications/Details]
|
||||
|
||||
For example:
|
||||
- 2x Air Compressor Filter P-AC500: 5 micron, high-efficiency as specified in technical document
|
||||
- 1x Preventive Maintenance Service: Annual service package including parts and labor
|
||||
- 3x Pneumatic Valves PV-230: 3/4" NPT connection, 150 PSI max pressure
|
||||
|
||||
If the client has provided pricing expectations or budget constraints, include this information. If specific delivery timeframes are mentioned, note these as well.
|
||||
"""
|
||||
elif template_type == 'general':
|
||||
return """You are a helpful AI assistant. Please analyze the following content and provide a concise and helpful response:
|
||||
|
||||
{content}
|
||||
"""
|
||||
else:
|
||||
return """Please provide a prompt template for this type of content."""
|
||||
|
||||
def set_as_default(self):
|
||||
"""Set this template as the default for its type"""
|
||||
# First, unset any existing default templates of the same type
|
||||
other_defaults = self.search([
|
||||
('id', '!=', self.id),
|
||||
('template_type', '=', self.template_type),
|
||||
('is_default', '=', True)
|
||||
])
|
||||
other_defaults.write({'is_default': False})
|
||||
|
||||
# Set this template as default
|
||||
self.is_default = True
|
||||
|
||||
# Update the system parameter if this is a helpdesk template
|
||||
if self.template_type == 'helpdesk':
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'helpdesk_sale_order_ai.default_prompt_template_id',
|
||||
str(self.id)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Override create to handle default templates"""
|
||||
records = super(AIPromptTemplate, self).create(vals_list)
|
||||
|
||||
# Group templates by type
|
||||
templates_by_type = {}
|
||||
for record in records:
|
||||
if record.is_default:
|
||||
if record.template_type not in templates_by_type:
|
||||
templates_by_type[record.template_type] = []
|
||||
templates_by_type[record.template_type].append(record.id)
|
||||
|
||||
# For each type with new default templates, unset default flag on others of the same type
|
||||
for template_type, template_ids in templates_by_type.items():
|
||||
self.search([
|
||||
('id', 'not in', template_ids),
|
||||
('is_default', '=', True),
|
||||
('template_type', '=', template_type)
|
||||
]).write({'is_default': False})
|
||||
|
||||
return records
|
||||
|
||||
@api.model
|
||||
def get_default_template(self, template_type='helpdesk'):
|
||||
"""Get the default template for the given type"""
|
||||
_logger.info(f"Getting default template for type: {template_type}")
|
||||
try:
|
||||
default_template = self.search([
|
||||
('is_default', '=', True),
|
||||
('template_type', '=', template_type),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
|
||||
_logger.info(f"Found default template: {bool(default_template)}")
|
||||
|
||||
if not default_template:
|
||||
_logger.info("No active default template found, ensuring one exists")
|
||||
# Create a default template if none exists
|
||||
default_template = self._ensure_default_template(template_type)
|
||||
_logger.info(f"Default template after ensure: {bool(default_template)} with ID: {default_template.id if default_template else 'None'}")
|
||||
else:
|
||||
_logger.info(f"Using existing default template with ID: {default_template.id}")
|
||||
|
||||
return default_template
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in get_default_template: {e}", exc_info=True)
|
||||
# Try to create a new template as a fallback
|
||||
try:
|
||||
_logger.info("Attempting to create a new default template as fallback")
|
||||
default_content = self._get_default_template_content(template_type)
|
||||
default_name = f"Fallback {template_type.capitalize()} Template"
|
||||
|
||||
fallback_template = self.create({
|
||||
'name': default_name,
|
||||
'template_type': template_type,
|
||||
'content': default_content,
|
||||
'is_default': True,
|
||||
})
|
||||
_logger.info(f"Created fallback template with ID: {fallback_template.id}")
|
||||
return fallback_template
|
||||
except Exception as e2:
|
||||
_logger.error(f"Failed to create fallback template: {e2}", exc_info=True)
|
||||
return self.browse() # Return empty recordset
|
||||
359
openwebui_connector/models/openwebui_client.py
Normal file
359
openwebui_connector/models/openwebui_client.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OpenWebUIClient(models.AbstractModel):
|
||||
"""
|
||||
OpenWebUI client for interacting with the OpenWebUI API.
|
||||
This is a simplified version of the external OpenWebUI client integrated into Odoo.
|
||||
"""
|
||||
_name = 'openwebui.client'
|
||||
_description = 'OpenWebUI Client for AI Services'
|
||||
|
||||
def _get_config(self, raise_if_missing=True):
|
||||
"""
|
||||
Get the OpenWebUI configuration from the system parameters.
|
||||
Uses the dedicated openwebui parameters for API key, base URL and model.
|
||||
|
||||
Args:
|
||||
raise_if_missing: If True, raise an error when API key is missing.
|
||||
If False, return config with empty API key.
|
||||
"""
|
||||
_logger.debug(f"Getting OpenWebUI config (raise_if_missing={raise_if_missing})")
|
||||
try:
|
||||
# Get the config from the ir.config_parameter
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Use dedicated OpenWebUI parameters
|
||||
api_key = IrConfigParam.get_param('openwebui.api_key', False)
|
||||
|
||||
# If OpenWebUI API key is not set, try fallback to OpenAI key
|
||||
if not api_key:
|
||||
api_key = IrConfigParam.get_param('openai.api_key', False)
|
||||
_logger.debug("OpenWebUI API key not found, falling back to OpenAI API key")
|
||||
|
||||
_logger.debug(f"Retrieved API key: {'Present' if api_key else 'Missing'}")
|
||||
|
||||
# Get base URL from dedicated parameter
|
||||
base_url = IrConfigParam.get_param('openwebui.base_url', 'https://ai.bemade.org/api')
|
||||
_logger.debug(f"Using base_url: {base_url}")
|
||||
|
||||
# Get model from dedicated parameter
|
||||
model = IrConfigParam.get_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
|
||||
_logger.debug(f"Using model: {model}")
|
||||
|
||||
if not api_key and raise_if_missing:
|
||||
_logger.error("No API key found in configuration and raise_if_missing=True")
|
||||
raise ValueError("No API key found in configuration")
|
||||
|
||||
config = {
|
||||
'api_key': api_key or '',
|
||||
'base_url': base_url,
|
||||
'model': model
|
||||
}
|
||||
_logger.debug("Successfully retrieved OpenWebUI config")
|
||||
return config
|
||||
except Exception as e:
|
||||
_logger.error(f"Error getting OpenWebUI config: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_available_models(self):
|
||||
"""
|
||||
Fetch available models from the OpenWebUI API.
|
||||
|
||||
Returns:
|
||||
A list of tuples (model_id, model_name) suitable for selection fields
|
||||
"""
|
||||
_logger.info("Starting get_available_models")
|
||||
# Define default_model at the beginning to ensure it's always available
|
||||
default_model = 'anthropic.claude-3-7-sonnet-latest'
|
||||
|
||||
# Create a list with just the default model to ensure it's always available
|
||||
models = [(default_model, default_model)]
|
||||
|
||||
try:
|
||||
# Get config without raising exception if API key is missing
|
||||
config = self._get_config(raise_if_missing=False)
|
||||
|
||||
# Update default_model with configured value and ensure it's in the list
|
||||
if 'model' in config and config['model']:
|
||||
default_model = config['model']
|
||||
# Clear the list and add the current default model
|
||||
models = [(default_model, default_model)]
|
||||
|
||||
_logger.info(f"Default model: {default_model}")
|
||||
|
||||
# If no API key is set, just return the current model
|
||||
if not config.get('api_key'):
|
||||
_logger.warning("No API key configured, only showing current model")
|
||||
return models
|
||||
|
||||
# Add some common models that we know work with OpenWebUI
|
||||
# This ensures we have a good selection even if the API call fails
|
||||
common_models = [
|
||||
('anthropic.claude-3-7-sonnet-latest', 'Claude 3.7 Sonnet'),
|
||||
('anthropic.claude-3-5-sonnet-20240620', 'Claude 3.5 Sonnet'),
|
||||
('anthropic.claude-3-opus-20240229', 'Claude 3 Opus'),
|
||||
('gpt-4o', 'GPT-4o'),
|
||||
('gpt-4-turbo', 'GPT-4 Turbo'),
|
||||
('gpt-3.5-turbo', 'GPT-3.5 Turbo')
|
||||
]
|
||||
|
||||
# Add common models to our list, but keep the default model first
|
||||
for model_id, model_name in common_models:
|
||||
if model_id != default_model: # Avoid duplicates
|
||||
models.append((model_id, model_name))
|
||||
|
||||
# Remove trailing slash if present in base_url
|
||||
base_url = config.get('base_url', 'https://ai.bemade.org/api')
|
||||
if isinstance(base_url, str) and base_url.endswith('/'):
|
||||
base_url = base_url[:-1]
|
||||
_logger.info(f"Using base URL: {base_url}")
|
||||
|
||||
# Check if base_url already contains '/api' to avoid duplicates
|
||||
if '/api' in base_url:
|
||||
endpoints = [
|
||||
'/v1/models',
|
||||
'/models',
|
||||
'/chat/models',
|
||||
'/v1/chat/models'
|
||||
]
|
||||
else:
|
||||
endpoints = [
|
||||
'/v1/models',
|
||||
'/models',
|
||||
'/chat/models',
|
||||
'/api/models',
|
||||
'/api/v1/models',
|
||||
'/api/chat/models'
|
||||
]
|
||||
_logger.debug(f"Will try these endpoints: {endpoints}")
|
||||
|
||||
# Set up authentication headers
|
||||
headers = {
|
||||
"Authorization": f"Bearer {config['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
_logger.debug("Authentication headers set up")
|
||||
|
||||
# Try a simple connection test first with a short timeout
|
||||
try:
|
||||
_logger.info(f"Testing connection to base URL: {base_url}")
|
||||
test_response = requests.get(base_url, timeout=3)
|
||||
_logger.info(f"Base API connection test: {test_response.status_code}")
|
||||
if test_response.status_code >= 400:
|
||||
_logger.warning(f"Base URL returned error status: {test_response.status_code}")
|
||||
return models # Return our predefined models
|
||||
except Exception as e:
|
||||
_logger.warning(f"Could not connect to base API URL: {str(e)}")
|
||||
# Return our predefined models if we can't connect
|
||||
_logger.info("Returning predefined models due to connection error")
|
||||
return models
|
||||
|
||||
# Try each endpoint with a short timeout
|
||||
_logger.info("Starting to try each endpoint for models")
|
||||
success = False
|
||||
|
||||
for endpoint in endpoints:
|
||||
url = f"{base_url}{endpoint}"
|
||||
_logger.info(f"Trying endpoint: {url}")
|
||||
|
||||
try:
|
||||
_logger.debug(f"Making request to {url} with timeout=5")
|
||||
response = requests.get(url, headers=headers, timeout=5)
|
||||
_logger.info(f"Response status code: {response.status_code}")
|
||||
|
||||
if response.status_code != 200:
|
||||
_logger.info(f"Endpoint {endpoint} returned non-200 status code: {response.status_code}")
|
||||
continue
|
||||
|
||||
# Check if response is empty
|
||||
if not response.text or response.text.strip() == '':
|
||||
_logger.warning(f"Empty response from {url}")
|
||||
continue
|
||||
|
||||
# Try to parse the response as JSON
|
||||
try:
|
||||
_logger.debug("Parsing response as JSON")
|
||||
response_data = response.json()
|
||||
_logger.debug(f"Response data type: {type(response_data)}")
|
||||
|
||||
# If we got an empty object or list, skip
|
||||
if (isinstance(response_data, dict) and not response_data) or \
|
||||
(isinstance(response_data, list) and not response_data):
|
||||
_logger.warning(f"Empty JSON object/array from {url}")
|
||||
continue
|
||||
|
||||
except json.JSONDecodeError as je:
|
||||
_logger.error(f"Failed to parse JSON from {url}: {je}")
|
||||
continue
|
||||
|
||||
# Extract model information from the response
|
||||
model_ids = []
|
||||
|
||||
# Handle different response formats
|
||||
if isinstance(response_data, dict):
|
||||
_logger.debug("Response is a dictionary")
|
||||
# OpenAI format
|
||||
if "data" in response_data and isinstance(response_data["data"], list):
|
||||
_logger.debug("Found OpenAI format response with 'data' key")
|
||||
for model in response_data["data"]:
|
||||
if isinstance(model, dict) and "id" in model:
|
||||
model_id = model["id"]
|
||||
model_name = model.get("name", model_id)
|
||||
model_ids.append((model_id, model_name))
|
||||
# Another common format
|
||||
elif "models" in response_data and isinstance(response_data["models"], list):
|
||||
_logger.debug("Found response with 'models' key")
|
||||
for model in response_data["models"]:
|
||||
if isinstance(model, dict) and "id" in model:
|
||||
model_id = model["id"]
|
||||
model_name = model.get("name", model_id)
|
||||
model_ids.append((model_id, model_name))
|
||||
else:
|
||||
_logger.debug(f"Dictionary response keys: {list(response_data.keys())}")
|
||||
elif isinstance(response_data, list):
|
||||
_logger.debug("Response is a list")
|
||||
# Simple list format
|
||||
for model in response_data:
|
||||
if isinstance(model, dict) and "id" in model:
|
||||
model_id = model["id"]
|
||||
model_name = model.get("name", model_id)
|
||||
model_ids.append((model_id, model_name))
|
||||
else:
|
||||
_logger.warning(f"Unexpected response data type: {type(response_data)}")
|
||||
|
||||
if model_ids:
|
||||
_logger.info(f"Found {len(model_ids)} models from endpoint {url}")
|
||||
# Add all found models to our list, avoiding duplicates
|
||||
for model_tuple in model_ids:
|
||||
if model_tuple not in models:
|
||||
models.append(model_tuple)
|
||||
success = True
|
||||
break
|
||||
else:
|
||||
_logger.warning(f"No models found in response from {url}")
|
||||
|
||||
except requests.RequestException as e:
|
||||
_logger.error(f"Request error with endpoint {endpoint}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error with endpoint {endpoint}: {e}")
|
||||
continue
|
||||
|
||||
# If we couldn't find any models from API, we still have our predefined models
|
||||
_logger.info(f"Total models found: {len(models)}")
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in get_available_models: {e}")
|
||||
# Return just the default model on any error
|
||||
return [(default_model, default_model)]
|
||||
|
||||
def chat_completion(self, messages, model=None):
|
||||
"""
|
||||
Send a chat completion request to the OpenWebUI API.
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries with 'role' and 'content' keys
|
||||
model: Model to use (defaults to the configured model)
|
||||
|
||||
Returns:
|
||||
The response from the OpenWebUI API
|
||||
"""
|
||||
_logger.info("Starting chat_completion request")
|
||||
try:
|
||||
# For chat completion, we do need a valid API key
|
||||
_logger.info("Getting config for chat completion")
|
||||
config = self._get_config(raise_if_missing=True)
|
||||
|
||||
# Use the provided model or fall back to the configured model
|
||||
model = model or config['model']
|
||||
_logger.info(f"Using model: {model}")
|
||||
|
||||
# Remove trailing slash if present in base_url
|
||||
base_url = config['base_url']
|
||||
if isinstance(base_url, str) and base_url.endswith('/'):
|
||||
base_url = base_url[:-1]
|
||||
_logger.info(f"Using base URL: {base_url}")
|
||||
|
||||
# Construct the full URL based on the API provider
|
||||
if 'openai.com' in base_url:
|
||||
# Standard OpenAI API format
|
||||
url = f"{base_url}/chat/completions"
|
||||
elif 'bemade.org' in base_url:
|
||||
# For Bemade's API, try without the v1 path as it's returning 405
|
||||
url = f"{base_url}/chat/completions"
|
||||
else:
|
||||
# Generic format, try standard OpenAI path
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
_logger.info(f"Full API URL: {url}")
|
||||
|
||||
# Set up authentication headers
|
||||
headers = {
|
||||
"Authorization": f"Bearer {config['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
_logger.debug("Authentication headers set up")
|
||||
|
||||
# Create the payload
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.7, # Add reasonable temperature for more consistent results
|
||||
"max_tokens": 4000 # Ensure we get enough tokens for a complete response
|
||||
}
|
||||
|
||||
_logger.info(f"OpenWebUI API request to: {url}")
|
||||
_logger.debug(f"OpenWebUI API payload: {payload}")
|
||||
|
||||
try:
|
||||
# Make the HTTP request
|
||||
_logger.info("Sending POST request to OpenWebUI API")
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=60.0)
|
||||
_logger.info(f"Response status code: {response.status_code}")
|
||||
|
||||
# Log response content for debugging
|
||||
_logger.debug(f"Response content (first 500 chars): {response.text[:500]}")
|
||||
|
||||
# Raise an exception for any HTTP error
|
||||
response.raise_for_status()
|
||||
_logger.info("Response status check passed")
|
||||
|
||||
# Parse the JSON response
|
||||
try:
|
||||
_logger.debug("Parsing response as JSON")
|
||||
response_data = response.json()
|
||||
except json.JSONDecodeError as je:
|
||||
_logger.error(f"Failed to parse JSON response: {je}", exc_info=True)
|
||||
_logger.error(f"Response content: {response.text[:500]}")
|
||||
return ""
|
||||
|
||||
_logger.debug(f"Response data type: {type(response_data)}")
|
||||
if isinstance(response_data, dict):
|
||||
_logger.debug(f"Response keys: {list(response_data.keys())}")
|
||||
|
||||
# Extract the content from the response
|
||||
if response_data and 'choices' in response_data and response_data['choices']:
|
||||
_logger.info("Successfully extracted content from response")
|
||||
return response_data['choices'][0]['message']['content']
|
||||
else:
|
||||
_logger.error(f"Invalid response format from OpenWebUI API: {response_data}")
|
||||
return ""
|
||||
|
||||
except requests.RequestException as re:
|
||||
_logger.error(f"Request error calling OpenWebUI API: {re}", exc_info=True)
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error in API request: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in chat_completion: {e}", exc_info=True)
|
||||
raise
|
||||
382
openwebui_connector/models/res_config_settings.py
Normal file
382
openwebui_connector/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# AI API Configuration (unified for all AI services)
|
||||
openai_api_key = fields.Char(
|
||||
string='AI API Key',
|
||||
help='API key for AI services (OpenAI, OpenWebUI, Claude)',
|
||||
config_parameter='openwebui.api_key',
|
||||
)
|
||||
|
||||
openai_base_url = fields.Char(
|
||||
string='AI Base URL',
|
||||
help='Base URL for AI API',
|
||||
default='https://api.openai.com/v1', # Default OpenAI endpoint
|
||||
config_parameter='openwebui.base_url',
|
||||
)
|
||||
|
||||
# Use Selection field with dynamic options from OpenWebUI client
|
||||
openai_model = fields.Selection(
|
||||
selection='_get_openwebui_models',
|
||||
string='AI Model',
|
||||
help='Model to use for AI API calls',
|
||||
default='anthropic.claude-3-7-sonnet-latest',
|
||||
config_parameter='openwebui.model',
|
||||
)
|
||||
|
||||
# Helpdesk AI fields
|
||||
helpdesk_use_ai_sale_orders = fields.Boolean(
|
||||
string='Use AI for Sale Orders',
|
||||
help='If checked, the system will use AI to automatically generate sale orders from helpdesk ticket descriptions.',
|
||||
config_parameter='helpdesk_sale_order_ai.use_ai_sale_orders',
|
||||
)
|
||||
|
||||
# Add a field to select the prompt template directly
|
||||
# In Odoo 18, we need to ensure this field is properly defined
|
||||
ai_prompt_template_id = fields.Integer(
|
||||
string='Default AI Prompt Template ID',
|
||||
help='Default template ID for the prompt sent to the AI.',
|
||||
compute='_compute_ai_prompt_template_id',
|
||||
inverse='_inverse_ai_prompt_template_id',
|
||||
store=False, # Don't store this field to avoid constraint issues
|
||||
)
|
||||
|
||||
# Actual relation field for the template, but not stored in database
|
||||
ai_prompt_template_relation = fields.Many2one(
|
||||
'openwebui.prompt.template',
|
||||
string='Default AI Prompt Template',
|
||||
help='Default template for the prompt sent to the AI.',
|
||||
compute='_compute_ai_prompt_template_relation',
|
||||
domain="[('template_type', '=', 'helpdesk')]",
|
||||
store=False, # Don't store this field to avoid constraint issues
|
||||
)
|
||||
|
||||
# Display field for backward compatibility
|
||||
helpdesk_ai_template_display = fields.Char(
|
||||
string='Default AI Prompt Template',
|
||||
help='Default template for the prompt sent to the AI.',
|
||||
compute='_compute_helpdesk_ai_template_display',
|
||||
readonly=True,
|
||||
store=False, # Important: don't store this field in the database
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_openwebui_models(self):
|
||||
"""Get available models from OpenWebUI API"""
|
||||
try:
|
||||
client = self.env['openwebui.client']
|
||||
models = client.get_available_models()
|
||||
return models
|
||||
except Exception as e:
|
||||
_logger.error(f"Error fetching OpenWebUI models: {e}")
|
||||
# Return default model on error
|
||||
default_model = 'anthropic.claude-3-7-sonnet-latest'
|
||||
return [(default_model, default_model)]
|
||||
|
||||
def _compute_ai_prompt_template_id(self):
|
||||
"""Compute the template ID from system parameter"""
|
||||
for record in self:
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
# Convert to integer if it's a string
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
record.ai_prompt_template_id = template_id_int
|
||||
except (ValueError, TypeError):
|
||||
record.ai_prompt_template_id = False
|
||||
else:
|
||||
record.ai_prompt_template_id = False
|
||||
|
||||
def _inverse_ai_prompt_template_id(self):
|
||||
"""Save the template ID to system parameter"""
|
||||
for record in self:
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
if record.ai_prompt_template_id:
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(record.ai_prompt_template_id))
|
||||
else:
|
||||
# Try to get a default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
|
||||
def _compute_ai_prompt_template_relation(self):
|
||||
"""Compute the template relation from the ID"""
|
||||
for record in self:
|
||||
if record.ai_prompt_template_id:
|
||||
template = self.env['openwebui.prompt.template'].browse(record.ai_prompt_template_id)
|
||||
if template.exists():
|
||||
record.ai_prompt_template_relation = template.id
|
||||
else:
|
||||
record.ai_prompt_template_relation = False
|
||||
else:
|
||||
record.ai_prompt_template_relation = False
|
||||
|
||||
@api.depends('ai_prompt_template_relation')
|
||||
def _compute_helpdesk_ai_template_display(self):
|
||||
"""Compute the template name from the selected template"""
|
||||
for record in self:
|
||||
if record.ai_prompt_template_relation:
|
||||
record.helpdesk_ai_template_display = record.ai_prompt_template_relation.name
|
||||
else:
|
||||
# Try to get from system parameter for backward compatibility
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
# Convert to integer if it's a string
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
|
||||
# Try to find the template in the new model
|
||||
template = self.env['openwebui.prompt.template'].browse(template_id_int)
|
||||
if template.exists():
|
||||
record.helpdesk_ai_template_display = template.name
|
||||
else:
|
||||
record.helpdesk_ai_template_display = f'Template #{template_id} (not found)'
|
||||
except (ValueError, TypeError):
|
||||
record.helpdesk_ai_template_display = f'Template #{template_id} (invalid)'
|
||||
else:
|
||||
record.helpdesk_ai_template_display = 'Default Template'
|
||||
|
||||
@api.model
|
||||
def _set_default_values(self):
|
||||
"""Set default values for configuration parameters"""
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
base_url = IrConfigParam.get_param('openwebui.base_url')
|
||||
model = IrConfigParam.get_param('openwebui.model')
|
||||
|
||||
# Check for old parameters and migrate them
|
||||
old_api_key = IrConfigParam.get_param('openai.api_key', False)
|
||||
old_base_url = IrConfigParam.get_param('openai.base_url', False)
|
||||
old_model = IrConfigParam.get_param('openai.model', False)
|
||||
|
||||
# Migrate old parameters to new ones if they exist
|
||||
if old_api_key and not IrConfigParam.get_param('openwebui.api_key', False):
|
||||
IrConfigParam.set_param('openwebui.api_key', old_api_key)
|
||||
|
||||
if old_base_url and not base_url:
|
||||
IrConfigParam.set_param('openwebui.base_url', old_base_url)
|
||||
|
||||
if old_model and not model:
|
||||
IrConfigParam.set_param('openwebui.model', old_model)
|
||||
|
||||
# Set defaults if not already set
|
||||
if not base_url:
|
||||
IrConfigParam.set_param('openwebui.base_url', 'https://api.openai.com/v1')
|
||||
|
||||
if not model:
|
||||
IrConfigParam.set_param('openwebui.model', 'anthropic.claude-3-7-sonnet-latest')
|
||||
|
||||
# Ensure there's a default template
|
||||
template_model = self.env['openwebui.prompt.template']
|
||||
template_model._ensure_default_template('helpdesk')
|
||||
default_template = template_model.get_default_template('helpdesk')
|
||||
|
||||
# Set the default template ID in config parameters if not set
|
||||
default_template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id')
|
||||
if not default_template_id and default_template:
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
|
||||
@api.model
|
||||
def create(self, vals_list):
|
||||
"""Set default values when creating settings"""
|
||||
self._set_default_values()
|
||||
return super(ResConfigSettings, self).create(vals_list)
|
||||
|
||||
def set_values(self):
|
||||
"""Set values from the settings form"""
|
||||
_logger.info("Starting set_values in ResConfigSettings")
|
||||
try:
|
||||
# First, explicitly save the API key, base URL, and model to ensure they're saved
|
||||
# even if there's an error with the template handling
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Save API key, base URL, and model directly with explicit error handling
|
||||
# API Key - most critical setting
|
||||
if hasattr(self, 'openai_api_key'):
|
||||
_logger.info(f"Saving API key (present: {bool(self.openai_api_key)})")
|
||||
# Always set the parameter, even if None or False, to clear previous value if needed
|
||||
api_key_value = self.openai_api_key if self.openai_api_key else ''
|
||||
IrConfigParam.set_param('openai.api_key', api_key_value)
|
||||
# Verify it was saved
|
||||
saved_key = IrConfigParam.get_param('openai.api_key', '')
|
||||
_logger.info(f"API key saved successfully: {bool(saved_key)}")
|
||||
|
||||
# Base URL
|
||||
if hasattr(self, 'openai_base_url'):
|
||||
_logger.info(f"Saving base URL: {self.openai_base_url}")
|
||||
base_url_value = self.openai_base_url if self.openai_base_url else 'https://ai.bemade.org/api'
|
||||
IrConfigParam.set_param('openai.base_url', base_url_value)
|
||||
# Verify it was saved
|
||||
saved_url = IrConfigParam.get_param('openai.base_url', '')
|
||||
_logger.info(f"Base URL saved: {saved_url}")
|
||||
|
||||
# Model
|
||||
if hasattr(self, 'openai_model'):
|
||||
_logger.info(f"Saving model: {self.openai_model}")
|
||||
model_value = self.openai_model if self.openai_model else 'anthropic.claude-3-7-sonnet-latest'
|
||||
IrConfigParam.set_param('openai.model', model_value)
|
||||
# Verify it was saved
|
||||
saved_model = IrConfigParam.get_param('openai.model', '')
|
||||
_logger.info(f"Model saved: {saved_model}")
|
||||
|
||||
_logger.info(f"Current settings values: openai_api_key={bool(self.openai_api_key)}, openai_base_url={self.openai_base_url}, openai_model={self.openai_model}")
|
||||
_logger.info(f"Template ID: {self.ai_prompt_template_id if self.ai_prompt_template_id else 'None'}")
|
||||
|
||||
# Force a commit to ensure parameters are saved to database
|
||||
self.env.cr.commit()
|
||||
_logger.info("Committed parameter changes to database")
|
||||
|
||||
# Now call the super method
|
||||
res = super(ResConfigSettings, self).set_values()
|
||||
_logger.info("Super set_values completed successfully")
|
||||
|
||||
# Handle the template ID - either save the selected one or ensure a default exists
|
||||
try:
|
||||
if self.ai_prompt_template_id:
|
||||
_logger.info(f"Saving template ID {self.ai_prompt_template_id} to system parameters")
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(self.ai_prompt_template_id))
|
||||
else:
|
||||
# If no template is selected, ensure a default one exists and use that
|
||||
_logger.info("No template selected, getting default template")
|
||||
template_model = self.env['openwebui.prompt.template']
|
||||
try:
|
||||
default_template = template_model.get_default_template('helpdesk')
|
||||
if default_template:
|
||||
_logger.info(f"Using default template ID {default_template.id}")
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
else:
|
||||
_logger.warning("No default template found")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error getting default template: {e}", exc_info=True)
|
||||
# Continue execution even if there's an error with the template
|
||||
except Exception as template_error:
|
||||
_logger.error(f"Error handling template: {template_error}", exc_info=True)
|
||||
# Continue execution even if there's an error with the template
|
||||
|
||||
# Force another commit to ensure all changes are saved
|
||||
self.env.cr.commit()
|
||||
_logger.info("Final commit completed")
|
||||
|
||||
# Double-check that the API key was saved
|
||||
final_api_key = IrConfigParam.get_param('openai.api_key', '')
|
||||
_logger.info(f"Final API key check - key exists: {bool(final_api_key)}")
|
||||
|
||||
_logger.info("set_values completed successfully")
|
||||
return res
|
||||
except Exception as e:
|
||||
_logger.error(f"Error in set_values: {e}", exc_info=True)
|
||||
# Try to save API key directly even if there was an error
|
||||
try:
|
||||
if hasattr(self, 'openai_api_key'):
|
||||
api_key_value = self.openai_api_key if self.openai_api_key else ''
|
||||
self.env['ir.config_parameter'].sudo().set_param('openai.api_key', api_key_value)
|
||||
self.env.cr.commit() # Force commit to save the key
|
||||
_logger.info("Saved API key directly after error")
|
||||
except Exception as key_error:
|
||||
_logger.error(f"Failed to save API key after error: {key_error}")
|
||||
raise
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
"""Get values for the settings form"""
|
||||
res = super(ResConfigSettings, self).get_values()
|
||||
|
||||
# Ensure default values are set
|
||||
self._set_default_values()
|
||||
|
||||
# Get all OpenAI settings from system parameters
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
# Get OpenAI API key, base URL, and model
|
||||
_logger.info("Retrieving OpenAI settings from system parameters")
|
||||
api_key = IrConfigParam.get_param('openai.api_key', False)
|
||||
base_url = IrConfigParam.get_param('openai.base_url', 'https://ai.bemade.org/api')
|
||||
model = IrConfigParam.get_param('openai.model', 'anthropic.claude-3-7-sonnet-latest')
|
||||
|
||||
# Set the values in the result dictionary
|
||||
res.update({
|
||||
'openai_api_key': api_key,
|
||||
'openai_base_url': base_url,
|
||||
'openai_model': model,
|
||||
})
|
||||
|
||||
_logger.info(f"Retrieved settings: API key present: {bool(api_key)}, base_url: {base_url}, model: {model}")
|
||||
|
||||
# Get the template ID from the system parameter
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
# Check if the template exists
|
||||
template = self.env['openwebui.prompt.template'].browse(template_id_int)
|
||||
if template.exists():
|
||||
res['ai_prompt_template_id'] = template_id_int
|
||||
else:
|
||||
# If template doesn't exist, get a default one
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
res['ai_prompt_template_id'] = default_template.id
|
||||
# Update the system parameter
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
except (ValueError, TypeError):
|
||||
_logger.error(f"Invalid template ID in system parameter: {template_id}")
|
||||
# Get a default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
res['ai_prompt_template_id'] = default_template.id
|
||||
# Update the system parameter
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
else:
|
||||
# No template ID in system parameters, get a default one
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template('helpdesk')
|
||||
if default_template:
|
||||
res['ai_prompt_template_id'] = default_template.id
|
||||
# Update the system parameter
|
||||
IrConfigParam.set_param('helpdesk_sale_order_ai.default_prompt_template_id', str(default_template.id))
|
||||
|
||||
return res
|
||||
|
||||
def action_open_prompt_templates(self):
|
||||
"""Open the prompt templates management view"""
|
||||
self.ensure_one()
|
||||
|
||||
return {
|
||||
'name': _('AI Prompt Templates'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'openwebui.prompt.template',
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_prompt_template(self, template_type='helpdesk'):
|
||||
"""Get the prompt template content from the selected template or default"""
|
||||
IrConfigParam = self.env['ir.config_parameter'].sudo()
|
||||
template_id = IrConfigParam.get_param('helpdesk_sale_order_ai.default_prompt_template_id', False)
|
||||
|
||||
if template_id:
|
||||
try:
|
||||
# Make sure the template model exists first
|
||||
self.env['openwebui.prompt.template']._ensure_default_template(template_type)
|
||||
# The template_id is already an integer in the database now
|
||||
template_id_int = int(template_id) if isinstance(template_id, str) else template_id
|
||||
template = self.env['openwebui.prompt.template'].browse(template_id_int)
|
||||
if template.exists():
|
||||
return template.content
|
||||
except (ValueError, TypeError) as e:
|
||||
_logger.error(f"Error retrieving prompt template: {e}")
|
||||
pass
|
||||
|
||||
# Fallback to default template
|
||||
default_template = self.env['openwebui.prompt.template'].get_default_template(template_type)
|
||||
return default_template.content if default_template else ""
|
||||
5
openwebui_connector/security/ir.model.access.csv
Normal file
5
openwebui_connector/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_openwebui_prompt_template_user,openwebui.prompt.template.user,model_openwebui_prompt_template,base.group_user,1,0,0,0
|
||||
access_openwebui_prompt_template_admin,openwebui.prompt.template.admin,model_openwebui_prompt_template,base.group_system,1,1,1,1
|
||||
access_openwebui_client_user,openwebui.client.user,model_openwebui_client,base.group_user,1,0,0,0
|
||||
access_openwebui_client_admin,openwebui.client.admin,model_openwebui_client,base.group_system,1,1,1,1
|
||||
|
109
openwebui_connector/views/ai_prompt_template_views.xml
Normal file
109
openwebui_connector/views/ai_prompt_template_views.xml
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- AI Prompt Template Form View -->
|
||||
<record id="view_openai_prompt_template_form" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.form</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Prompt Template">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="set_as_default" type="object"
|
||||
class="oe_stat_button" icon="fa-star"
|
||||
invisible="is_default">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Set as Default</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="set_as_default" type="object"
|
||||
class="oe_stat_button" icon="fa-star" disabled="1"
|
||||
invisible="not is_default">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Default Template</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Template Name"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="template_type"/>
|
||||
<field name="sequence" groups="base.group_no_one"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="is_default" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Template Content" name="template_content">
|
||||
<div class="text-muted mb-3">
|
||||
<div invisible="template_type != 'helpdesk'">
|
||||
Use placeholders like {description}, {customer}, etc. to include ticket information in the prompt.
|
||||
</div>
|
||||
<div invisible="template_type != 'general'">
|
||||
Use placeholders like {content} to include content in the prompt.
|
||||
</div>
|
||||
</div>
|
||||
<field name="content" widget="text" class="oe_edit_only" style="min-height: 300px;"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template List View -->
|
||||
<record id="view_openai_prompt_template_list" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.list</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="template_type"/>
|
||||
<field name="is_default" string="Default"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template Search View -->
|
||||
<record id="view_openai_prompt_template_search" model="ir.ui.view">
|
||||
<field name="name">openwebui.prompt.template.search</field>
|
||||
<field name="model">openwebui.prompt.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Prompt Templates">
|
||||
<field name="name"/>
|
||||
<filter string="Default" name="default" domain="[('is_default', '=', True)]"/>
|
||||
<filter string="Helpdesk" name="helpdesk" domain="[('template_type', '=', 'helpdesk')]"/>
|
||||
<filter string="General" name="general" domain="[('template_type', '=', 'general')]"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Template Type" name="group_by_type" domain="[]" context="{'group_by': 'template_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Prompt Template Action -->
|
||||
<record id="action_openai_prompt_template" model="ir.actions.act_window">
|
||||
<field name="name">AI Prompt Templates</field>
|
||||
<field name="res_model">openwebui.prompt.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new AI prompt template
|
||||
</p>
|
||||
<p>
|
||||
Define templates for AI prompts used in various modules.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item under Settings -->
|
||||
<menuitem id="menu_openai_prompt_template"
|
||||
name="AI Prompt Templates"
|
||||
parent="base.menu_administration"
|
||||
action="action_openai_prompt_template"
|
||||
sequence="50"/>
|
||||
</odoo>
|
||||
68
openwebui_connector/views/res_config_settings_views.xml
Normal file
68
openwebui_connector/views/res_config_settings_views.xml
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.openai.connector</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority">20</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="AI Services" string="AI Services" name="openwebui_connector" logo="/base/static/description/settings.png">
|
||||
<!-- API Configuration Block -->
|
||||
<block title="AI API Configuration" name="openai_settings_container">
|
||||
<setting id="openai_api_settings">
|
||||
<span class="o_form_label">API Configuration</span>
|
||||
<div class="text-muted">
|
||||
Configure your AI API credentials (OpenAI, OpenWebUI, Claude)
|
||||
</div>
|
||||
<div class="content-group mt16">
|
||||
<div class="row mt16">
|
||||
<label for="openai_api_key" class="col-lg-3 o_light_label">API Key</label>
|
||||
<field name="openai_api_key" password="True"/>
|
||||
</div>
|
||||
<div class="row mt16">
|
||||
<label for="openai_base_url" class="col-lg-3 o_light_label">API Base URL</label>
|
||||
<field name="openai_base_url" placeholder="https://ai.bemade.org/api"/>
|
||||
</div>
|
||||
<div class="row mt16">
|
||||
<label for="openai_model" class="col-lg-3 o_light_label">AI Model</label>
|
||||
<field name="openai_model" placeholder="anthropic.claude-3-7-sonnet-latest"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Helpdesk AI Settings Block -->
|
||||
<block title="Helpdesk AI Settings" name="helpdesk_ai_settings_container">
|
||||
<setting id="ai_sales_order_toggle">
|
||||
<field name="helpdesk_use_ai_sale_orders"/>
|
||||
<label for="helpdesk_use_ai_sale_orders"/>
|
||||
<div class="text-muted">
|
||||
Enable AI-powered sales order generation from helpdesk tickets
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="ai_prompt_template" invisible="not helpdesk_use_ai_sale_orders">
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ai_prompt_template_relation"/>
|
||||
<div class="text-muted">
|
||||
Default template for the prompt sent to the AI
|
||||
</div>
|
||||
<div class="mt8 d-flex align-items-center">
|
||||
<!-- Select the template directly -->
|
||||
<field name="ai_prompt_template_relation"/>
|
||||
<button name="action_open_prompt_templates" type="object" class="btn btn-link ps-2">
|
||||
<i class="fa fa-external-link" title="Manage Templates"/> Manage Templates
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-muted">
|
||||
<small>The template content is stored in the system and can be accessed programmatically.</small>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in a new issue