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.
359 lines
17 KiB
Python
359 lines
17 KiB
Python
# -*- 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
|