fuck my life with ollama

This commit is contained in:
Benoît Vézina 2025-02-18 14:55:35 -05:00
parent a3b4d3ed39
commit b95ca97022
13 changed files with 2253 additions and 158 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'OpenWebUI Integration',
'version': '18.0.1.0.1',
'version': '18.0.1.1.0',
'summary': 'Core integration with OpenWebUI',
'sequence': 10,
'description': """
@ -30,4 +30,5 @@ Features:
'auto_install': False,
'license': 'LGPL-3',
'i18n': True,
'post_init_hook': 'post_init_hook',
}

View file

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
def post_init_hook(cr, registry):
"""Post-init hook for initializing new columns."""
# Initialize ollama_port with default value
cr.execute("""
ALTER TABLE res_company
ADD COLUMN IF NOT EXISTS ollama_port integer DEFAULT 11434;
""")

View file

@ -1 +0,0 @@
from . import pre-migration

View file

@ -1,17 +0,0 @@
def migrate(cr, version):
"""Add products_per_request column to res_company table."""
if not version:
return
# Add products_per_request column if it doesn't exist
cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='res_company'
AND column_name='openwebui_products_per_request'
""")
if not cr.fetchone():
cr.execute("""
ALTER TABLE res_company
ADD COLUMN openwebui_products_per_request integer DEFAULT 10
""")

View file

@ -75,13 +75,16 @@ class OpenWebUIBotMixin(models.AbstractModel):
# Send the message to the model
_logger.info('Attempt %d/%d: Sending message to model (timeout=%ds)',
attempt + 1, max_retries, timeout)
_logger.info('Sending message to model: %r', message)
response = model.send_message(message=message, timeout=timeout)
_logger.info('Raw response from model: %r', response)
# Si la réponse contient une erreur de connexion, on la traite comme une exception
if isinstance(response, str) and "Connection error" in response:
_logger.error('Connection error in response: %r', response)
raise ConnectionError(response)
_logger.info('Received response from model: %r', response)
_logger.info('Processed response from model: %r', response)
if not response:
raise ValueError("Empty response from model")
@ -89,18 +92,62 @@ class OpenWebUIBotMixin(models.AbstractModel):
if not isinstance(response, str):
raise ValueError(f"Invalid response type: {type(response)}")
# Debug de la réponse brute
_logger.info('Raw response from model: %r', response)
# Clean up the response
# Supprimer les blocs de code markdown
# Supprimer les blocs de code markdown et autres caractères problématiques
response = re.sub(r'```json\n|\n```', '', response.strip())
_logger.debug('Response after markdown cleanup: %r', response)
response = re.sub(r'\\([^\\])', r'\1', response) # Supprimer les backslashes simples
response = re.sub(r'">\\n', '",', response) # Corriger le format des fins de lignes
response = re.sub(r'\\n\s*]\\n"}$', ']}', response) # Corriger la fin du JSON
_logger.info('Response after cleanup: %r', response)
# Debug des lignes individuelles
lines = response.strip().split('\n')
_logger.info('Number of response lines: %d', len(lines))
for i, line in enumerate(lines):
_logger.info('Line %d: %r', i + 1, line)
try:
# Essayer de parser directement la réponse nettoyée
parsed_response = json.loads(response)
# Paramètres de retry pour le parsing JSON
max_json_retries = 3
json_retry_delay = 2 # secondes
last_json_error = None
for json_attempt in range(max_json_retries):
try:
# Pour Ollama, la réponse peut être une série de JSON séparés par des newlines
# On prend le dernier JSON qui devrait être la réponse finale
json_responses = [json.loads(line) for line in response.strip().split('\n') if line.strip()]
if json_responses:
parsed_response = json_responses[-1] # Prendre le dernier JSON
else:
parsed_response = json.loads(response)
# Si on arrive ici, le parsing a réussi
break
except json.JSONDecodeError as e:
last_json_error = e
if json_attempt < max_json_retries - 1:
_logger.warning('JSON parsing attempt %d/%d failed: %s. Retrying in %d seconds...',
json_attempt + 1, max_json_retries, str(e), json_retry_delay)
time.sleep(json_retry_delay)
else:
_logger.error('All %d JSON parsing attempts failed. Last error: %s',
max_json_retries, str(e))
raise ValueError(f"Failed to parse JSON after {max_json_retries} attempts: {str(e)}")
# Si c'est un dictionnaire avec une clé 'response', extraire la valeur
if isinstance(parsed_response, dict):
if 'response' in parsed_response:
# Pour Ollama, la réponse est directement dans la clé 'response'
clean_response = parsed_response['response']
return self._process_bot_response(values, clean_response)
clean_response = response
except json.JSONDecodeError as e:
_logger.warning('Failed to parse response directly: %s', e)
# Si échec, chercher une liste JSON dans la réponse
# Si le parsing initial a échoué, essayer d'extraire une liste JSON
if last_json_error is not None:
_logger.warning('Failed to parse response directly, trying to extract JSON list')
start = response.find('[')
end = response.rfind(']')
@ -111,11 +158,30 @@ class OpenWebUIBotMixin(models.AbstractModel):
clean_response = response[start:end + 1]
_logger.debug('Extracted JSON list: %r', clean_response)
try:
parsed_response = json.loads(clean_response)
except json.JSONDecodeError as e:
_logger.error('Failed to parse extracted JSON: %s', e)
raise ValueError("Invalid JSON list in response")
# Nouveau cycle de retry pour le JSON extrait
for json_attempt in range(max_json_retries):
try:
parsed_response = json.loads(clean_response)
# Si on arrive ici, le parsing a réussi
break
except json.JSONDecodeError as e:
last_json_error = e
if json_attempt < max_json_retries - 1:
_logger.warning('Extracted JSON parsing attempt %d/%d failed: %s. Retrying in %d seconds...',
json_attempt + 1, max_json_retries, str(e), json_retry_delay)
time.sleep(json_retry_delay)
else:
_logger.error('All %d extracted JSON parsing attempts failed. Last error: %s',
max_json_retries, str(e))
raise ValueError("Invalid JSON list in response")
# Extraire la liste de produits si présente
if isinstance(parsed_response, dict):
if 'products' in parsed_response:
parsed_response = parsed_response['products']
elif 'response' in parsed_response:
# Cas précédent où la réponse est dans la clé 'response'
parsed_response = json.loads(parsed_response['response'])
if not isinstance(parsed_response, list):
_logger.error('Response is not a list: %r', parsed_response)

View file

@ -115,32 +115,108 @@ class OpenWebUIModel(models.Model):
str: The model's response or error message
"""
self.ensure_one()
company = self.env.company
# Initialize messages list with history if provided
messages = []
if message_history:
messages.extend(message_history)
# Add the current message
messages.append({'role': 'user', 'content': message})
data = {
'model': self.identifier,
'messages': messages,
}
# Base data structure
if company.ai_provider == 'ollama':
# Format for Ollama API
data = {
'model': self.identifier,
'stream': False, # We want a single response
'options': {
'num_ctx': company.openwebui_context_size,
'temperature': 0.7 # Default temperature
}
}
# Handle message history and current message
if message_history:
messages = []
for msg in message_history:
messages.append({
'role': msg['role'],
'content': msg['content']
})
messages.append({'role': 'user', 'content': message})
data['messages'] = messages
else:
# For single message, use simple prompt
if instructions:
# If system instructions are provided, use chat format
data['messages'] = [
{'role': 'system', 'content': instructions},
{'role': 'user', 'content': message}
]
else:
# For simple queries, use generate endpoint
data = {
'model': self.identifier,
'prompt': message,
'stream': False,
'options': data['options']
}
endpoint = 'api/generate'
if not 'endpoint' in locals():
endpoint = 'api/chat'
else: # openwebui
# Format for OpenWebUI API
data = {
'model': self.identifier,
'messages': message_history or []
}
data['messages'].append({'role': 'user', 'content': message})
if instructions:
data['system'] = instructions
if context:
data['context'] = context
data['options'] = {
'num_ctx': company.openwebui_context_size
}
endpoint = 'chat/completions'
if context:
data['context'] = context
if instructions:
data['system'] = instructions
success, result = self._make_request('chat/completions', method='POST', data=data, timeout=timeout)
_logger.info('Sending request to %s with data: %r', endpoint, data)
success, result = self._make_request(endpoint, method='POST', data=data, timeout=timeout)
if not success:
_logger.error('Request failed: %s', result)
return f"Error: {result}"
_logger.info('Raw API response: %r', result)
try:
return result['choices'][0]['message']['content']
except (KeyError, IndexError) as e:
# Handle Ollama response format
if isinstance(result, dict):
if 'response' in result: # Ollama chat/generate response
return result['response']
elif 'message' in result: # Ollama chat response alternative format
return result['message']['content']
elif 'choices' in result: # OpenWebUI format
return result['choices'][0]['message']['content']
else:
_logger.error('Unexpected API response format: %r', result)
return f"Error: Unexpected response format from API"
# Handle streaming response (should not happen with stream=False)
elif isinstance(result, str):
try:
# Parse the last line of a streaming response
lines = [line.strip() for line in result.split('\n') if line.strip()]
if lines:
last_response = json.loads(lines[-1])
if 'response' in last_response:
return last_response['response']
except json.JSONDecodeError as e:
_logger.error('Failed to parse streaming response: %s', e)
return result # Return raw string if parsing fails
return f"Error: Unexpected response type: {type(result)}"
except Exception as e:
_logger.error('Error processing API response: %s', str(e))
return f"Error processing response: {str(e)}"
def cleanup_temp_models(self):
@ -217,7 +293,32 @@ class OpenWebUIModel(models.Model):
return False, "OpenWebUI is not enabled"
try:
url = f"{config['api_url'].rstrip('/')}/api/{endpoint.lstrip('/')}"
api_url = config['api_url']
if not api_url:
return False, "OpenWebUI API URL is not configured"
# Add scheme if missing
if not api_url.startswith(('http://', 'https://')):
api_url = f'http://{api_url}'
# Check if port is included in URL
from urllib.parse import urlparse
parsed_url = urlparse(api_url)
if not parsed_url.port:
# Use Ollama port from company configuration
company = self.env.company
netloc = parsed_url.netloc
if ':' in netloc:
host = netloc.split(':')[0]
else:
host = netloc
api_url = f"{parsed_url.scheme}://{host}:{company.ollama_port}"
# Pour Ollama, les endpoints incluent déjà /api/
if endpoint.startswith('api/'):
url = f"{api_url.rstrip('/')}/{endpoint.lstrip('/')}"
else:
url = f"{api_url.rstrip('/')}/api/{endpoint.lstrip('/')}"
headers = {
'Accept': 'application/json'
}
@ -240,10 +341,46 @@ class OpenWebUIModel(models.Model):
elif method in ['POST', 'PUT', 'PATCH']:
kwargs['json'] = data
# Send the request
response = requests.request(method=method, url=url, **kwargs)
response.raise_for_status()
return True, response.json()
# Log raw response
response_text = response.text.strip()
_logger.info('Raw response text: %r', response_text)
# Split response into lines (Ollama may send multiple JSON objects)
response_lines = [line.strip() for line in response_text.split('\n') if line.strip()]
# Try to parse each line as JSON
parsed_responses = []
for line in response_lines:
try:
parsed_json = json.loads(line)
if isinstance(parsed_json, dict):
# Handle Ollama message format
if 'message' in parsed_json and 'content' in parsed_json['message']:
content = parsed_json['message']['content']
if content:
try:
# Try to parse content as JSON
content_json = json.loads(content)
parsed_responses.append(content_json)
except json.JSONDecodeError:
# Content is not JSON
parsed_responses.append(content)
else:
parsed_responses.append(parsed_json)
except json.JSONDecodeError:
# Skip invalid JSON lines
continue
# Return the last valid parsed response
if parsed_responses:
return True, parsed_responses[-1]
# If no valid JSON found, return the raw text
return True, response_text
except requests.exceptions.SSLError:
return False, "SSL/TLS verification failed"
@ -260,7 +397,7 @@ class OpenWebUIModel(models.Model):
def test_connection(self):
"""Test the connection to the OpenWebUI API.
This method performs a simple request to the /api/models endpoint
This method performs a simple request to the /api/chat endpoint
to verify that:
1. The API URL is accessible
2. The credentials are valid
@ -274,37 +411,46 @@ class OpenWebUIModel(models.Model):
Note:
Uses a reduced timeout of 5 seconds to avoid long waits.
"""
return self._make_request('models', timeout=5)
return self._make_request('api/chat', timeout=5)
@api.model
def get_available_models(self):
"""Get the list of available models from the OpenWebUI API.
"""Get the list of available models from the OpenWebUI/Ollama API.
This method attempts to retrieve the list of models from the API.
For Ollama, it uses the /api/tags endpoint.
If it fails, it returns a default list of common models.
Returns:
list: A list of tuples (id, name) of available models.
Example: [('gpt-3.5-turbo', 'GPT-3.5 Turbo'), ('gpt-4', 'GPT-4')]
Example: [('llama2', 'Llama 2'), ('mistral', 'Mistral')]
Note:
- Handles both possible API response formats (list or dict)
- Handles both Ollama and OpenWebUI API response formats
- Returns DEFAULT_MODELS in case of error or unexpected format
- Errors are logged but don't interrupt execution
"""
success, result = self._make_request('models')
# Try Ollama endpoint first
success, result = self._make_request('api/tags')
if not success:
_logger.error(f"Error fetching models: {result}")
return DEFAULT_MODELS
# Try OpenWebUI endpoint as fallback
success, result = self._make_request('models')
if not success:
_logger.error(f"Error fetching models: {result}")
return DEFAULT_MODELS
try:
if isinstance(result, list):
# Handle Ollama response format
if isinstance(result, dict) and 'models' in result:
return [(model['name'], model.get('name', model['name'])) for model in result['models']]
# Handle OpenWebUI response format
elif isinstance(result, list):
return [(model['id'], model.get('name', model['id'])) for model in result]
elif isinstance(result, dict) and 'data' in result:
return [(model['id'], model.get('name', model['id'])) for model in result['data']]
else:
_logger.warning("Unexpected response format from OpenWebUI API")
_logger.warning("Unexpected response format from API")
return DEFAULT_MODELS
except (KeyError, TypeError, AttributeError) as e:
_logger.error(f"Error processing models response: {str(e)}")
@ -327,7 +473,7 @@ class OpenWebUIModel(models.Model):
@api.model
def _sync_models(self):
"""Internal method to synchronize models from OpenWebUI.
"""Internal method to synchronize models from OpenWebUI/Ollama.
This method performs the actual synchronization work:
1. Cleans up temporary models
@ -342,27 +488,47 @@ class OpenWebUIModel(models.Model):
Note:
This is an internal method called by sync_models().
It should not be called directly unless you need fine-grained control.
Supports both Ollama and OpenWebUI API formats.
"""
# First clean up temporary models
self.cleanup_temp_models()
success, result = self._make_request('models')
# Try Ollama endpoint first
success, result = self._make_request('api/tags')
if not success:
return False, result
# Try OpenWebUI endpoint as fallback
success, result = self._make_request('models')
if not success:
return False, result
try:
models_data = result if isinstance(result, list) else result.get('data', [])
for model_data in models_data:
existing = self.search([('identifier', '=', model_data['id'])])
if not existing:
self.with_context(sync_models=True).create({
'name': model_data.get('name', model_data['id']),
'identifier': model_data['id'],
'description': model_data.get('description', ''),
})
# Handle Ollama response format
if isinstance(result, dict) and 'models' in result:
models_data = result['models']
for model_data in models_data:
existing = self.search([('identifier', '=', model_data['name'])])
if not existing:
self.with_context(sync_models=True).create({
'name': model_data['name'],
'identifier': model_data['name'],
'description': model_data.get('description', ''),
})
# Handle OpenWebUI response format
else:
models_data = result if isinstance(result, list) else result.get('data', [])
for model_data in models_data:
existing = self.search([('identifier', '=', model_data['id'])])
if not existing:
self.with_context(sync_models=True).create({
'name': model_data.get('name', model_data['id']),
'identifier': model_data['id'],
'description': model_data.get('description', ''),
})
return True, None
except (KeyError, TypeError) as e:
_logger.error(f"Error processing model data: {str(e)}")
return False, f"Error processing model data: {str(e)}"
return False, f"Invalid model data format: {str(e)}"
except odoo.exceptions.AccessError as e:
_logger.error(f"Access error while creating model: {str(e)}")

View file

@ -6,13 +6,34 @@ including service activation, API keys, and other configuration parameters.
"""
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, _
from odoo import models, fields, api, tools, _
from odoo.exceptions import UserError
class ResCompany(models.Model):
_inherit = 'res.company'
ai_provider = fields.Selection([
('openwebui', 'OpenWebUI'),
('ollama', 'Ollama')
],
string='AI Provider',
default='openwebui',
required=True,
help="Select the AI provider to use"
)
# Ollama Configuration
ollama_port = fields.Integer(
string='Ollama Port',
default=11434,
required=True,
help="Port number for the Ollama server (default: 11434)"
)
# OpenWebUI Configuration
openwebui_enabled = fields.Boolean(
string='Enable OpenWebUI',
default=False,
@ -41,6 +62,54 @@ class ResCompany(models.Model):
help="Maximum wait time for API calls"
)
openwebui_context_size = fields.Integer(
string='Context Window Size',
default=4096,
help="Maximum number of tokens in the context window (default: 4096)"
)
openwebui_max_products = fields.Integer(
string='Maximum Products per Batch',
default=800,
help="Maximum number of products that can be processed at once (default: 800)"
def test_ollama_connection(self):
"""Test the connection to Ollama server.
Returns:
dict: A notification action with the test result
"""
self.ensure_one()
if self.ai_provider != 'ollama':
raise UserError(_("Please select Ollama as the AI provider first."))
try:
import requests
# Test connection to Ollama server
url = f'http://localhost:{self.ollama_port}/api/tags'
response = requests.get(url, timeout=5)
response.raise_for_status()
# If we get here, the connection was successful
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Successfully connected to Ollama server on port %s', self.ollama_port),
'sticky': False,
'type': 'success',
}
}
except requests.exceptions.ConnectionError:
raise UserError(_("Could not connect to Ollama server. Please check if Ollama is running and the port number is correct."))
except requests.exceptions.Timeout:
raise UserError(_("Connection to Ollama server timed out. Please check your network settings."))
except requests.exceptions.RequestException as e:
raise UserError(_("Error connecting to Ollama server: %s", str(e)))
openwebui_products_per_request = fields.Integer(
string='Products per Request',
default=10,
@ -59,6 +128,12 @@ class ResCompany(models.Model):
domain="[('company_id', '=', id), ('is_active', '=', True)]",
help="Default OpenWebUI model to use for AI requests"
)
openwebui_context_size = fields.Integer(
string='Context Window Size',
default=2048,
help="Size of the context window in tokens (e.g., 2048, 4096, 8192)"
)
def test_openwebui_connection(self):
"""Test the connection to OpenWebUI API.
@ -108,63 +183,116 @@ class ResCompany(models.Model):
}
def refresh_model_list(self):
"""Synchronize available AI models from OpenWebUI with Odoo.
"""Synchronize available AI models with Odoo.
This method performs the following operations:
1. Verifies that OpenWebUI integration is enabled for the company
2. Connects to the OpenWebUI API to fetch the latest model list
3. Updates the local database:
- Creates records for new models
- Updates existing model information
- Archives models that are no longer available
This method performs the following operations based on the selected AI provider:
Technical Details:
- Uses the sync_models context to trigger a full synchronization
- Performs all operations in a single transaction
- Handles API connection errors gracefully
For OpenWebUI:
1. Verifies that OpenWebUI integration is enabled
2. Connects to the OpenWebUI API to fetch the latest model list
3. Updates the local database with model information
For Ollama:
1. Connects to the Ollama server
2. Fetches available models using the Ollama API
3. Updates the local database with model information
Returns:
dict: An action dictionary with the following structure:
{
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': str,
'message': str,
'sticky': bool,
'type': str,
}
}
dict: A notification action with the sync results
Raises:
UserError: In the following cases:
- OpenWebUI is not enabled for the company
- API connection fails
- Model synchronization fails
Example:
>>> company = env['res.company'].browse(1)
>>> result = company.refresh_model_list()
>>> print(result['params']['message'])
'Models list has been refreshed successfully!'
UserError: If there are connection or synchronization issues
"""
self.ensure_one()
if not self.openwebui_enabled:
raise UserError(_("OpenWebUI is not enabled for this company."))
Model = self.env['openwebui.model'].with_context(sync_models=True)
success, result = Model._sync_models()
if self.ai_provider == 'ollama':
return self._refresh_ollama_models()
elif self.ai_provider == 'openwebui':
if not self.openwebui_enabled:
raise UserError(_("OpenWebUI is not enabled for this company."))
return self._refresh_openwebui_models()
else:
raise UserError(_("Unknown AI provider: %s", self.ai_provider))
def _refresh_ollama_models(self):
"""Fetch and sync available models from Ollama server."""
try:
import requests
url = f'http://localhost:{self.ollama_port}/api/tags'
response = requests.get(url, timeout=5)
response.raise_for_status()
models_data = response.json()
# Get the OpenWebUI model object
Model = self.env['openwebui.model'].with_context(sync_models=True)
# Process each model from Ollama
for model in models_data.get('models', []):
name = model.get('name', '')
if not name:
continue
# Split name and tag if present (format: name:tag)
identifier = name
if ':' in name:
name, tag = name.split(':', 1)
identifier = f"{name}:{tag}"
else:
identifier = f"{name}:latest"
# Check if model already exists
existing_model = Model.search([
('identifier', '=', identifier),
('company_id', '=', self.id)
], limit=1)
model_vals = {
'name': name,
'identifier': identifier,
'is_active': True,
'description': f"Ollama model: {name}",
'company_id': self.id
}
if existing_model:
# Update existing model
existing_model.write(model_vals)
else:
# Create new model
Model.create(model_vals)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Successfully synchronized Ollama models'),
'sticky': False,
'type': 'success',
}
}
except requests.exceptions.ConnectionError:
raise UserError(_("Could not connect to Ollama server. Please check if Ollama is running and the port number is correct."))
except requests.exceptions.Timeout:
raise UserError(_("Connection to Ollama server timed out. Please check your network settings."))
except requests.exceptions.RequestException as e:
raise UserError(_("Error connecting to Ollama server: %s", str(e)))
except Exception as e:
raise UserError(_("Error synchronizing Ollama models: %s", str(e)))
def _refresh_openwebui_models(self):
"""Fetch and sync available models from OpenWebUI."""
success, result = self.env['openwebui.model'].sync_models()
if not success:
raise UserError(_("Failed to refresh models: %s") % result)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Models list has been refreshed successfully!'),
'message': _('Successfully synchronized OpenWebUI models'),
'sticky': False,
'type': 'success',
}

View file

@ -6,10 +6,29 @@
<field name="inherit_id" ref="base.view_company_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="OpenWebUI" name="openwebui">
<page string="AI Configuration" name="ai_config">
<group>
<group string="Provider Selection">
<field name="ai_provider"/>
</group>
</group>
<!-- Ollama Configuration -->
<group string="Ollama Configuration" invisible="ai_provider != 'ollama'">
<group>
<field name="openwebui_enabled"/>
<field name="ollama_port" required="ai_provider == 'ollama'"/>
<button name="test_ollama_connection"
string="Test Connection"
type="object"
class="btn-primary"
invisible="ai_provider != 'ollama'"/>
</group>
</group>
<!-- OpenWebUI Configuration -->
<group string="OpenWebUI Configuration" invisible="ai_provider != 'openwebui'">
<group>
<field name="openwebui_enabled" required="ai_provider == 'openwebui'"/>
<field name="openwebui_api_url"
invisible="not openwebui_enabled"
required="openwebui_enabled"
@ -24,6 +43,9 @@
invisible="not openwebui_enabled"/>
<field name="openwebui_timeout"
invisible="not openwebui_enabled"/>
<field name="openwebui_context_size"
invisible="not openwebui_enabled"
help="Size of the context window in tokens (e.g., 2048, 4096, 8192)"/>
<field name="openwebui_products_per_request"
invisible="not openwebui_enabled"
help="Number of products to process in a single API request"/>
@ -34,6 +56,19 @@
invisible="not openwebui_enabled"/>
</group>
</group>
<!-- Ollama Configuration -->
<group string="Ollama Configuration" invisible="ai_provider != 'ollama'">
<group>
<field name="openwebui_api_url"
required="ai_provider == 'ollama'"
placeholder="http://localhost:11434"/>
<field name="openwebui_context_size"
help="Size of the context window in tokens (e.g., 2048, 4096, 8192)"/>
<field name="openwebui_products_per_request"
help="Number of products to process in a single API request"/>
</group>
</group>
<group string="Models Configuration" invisible="not openwebui_enabled">
<field name="openwebui_default_model_id"
options="{'no_create': True, 'no_open': True}"

View file

@ -55,21 +55,24 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
def _generate_bot_message(self, records, values, command=None):
"""Generates the message to send to the bot to get category suggestions."""
# Préparer les données des produits
products_data = [{
'odoo_id': record.id, # ID interne Odoo
'name': record.name,
'description': record.description or '',
'description_sale': record.description_sale or '',
'default_code': record.default_code or '',
'current_category': record.categ_id.display_name,
'sellers': [
{
'name': seller.partner_id.display_name,
'product_code': seller.product_code or '',
'product_name': seller.product_name or ''
} for seller in record.seller_ids
]
} for record in records]
# Prepare product data for AI analysis
products_data = []
for record in records:
products_data.append({
'odoo_id': record.id, # Unique Odoo product ID
'name': record.name,
'description': record.description or '',
'description_sale': record.description_sale or '',
'default_code': record.default_code or '',
'current_category': record.categ_id.display_name,
'sellers': [
{
'name': seller.partner_id.display_name,
'product_code': seller.product_code or '',
'product_name': seller.product_name or ''
} for seller in record.seller_ids
]
})
# Récupérer toutes les catégories disponibles
Category = self.env['product.category']
@ -88,11 +91,37 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
'available_categories': available_categories,
'instructions': """For each product in the products list, analyze the product information and suggest the most appropriate product category from the available list.
Consider each product's name, description, and supplier information to make the best match.
Return a list of JSON objects, one for each product, with:
- 'odoo_id' (integer): The internal Odoo ID of the product
- 'category_id' (integer): The ID of the most appropriate category
- 'confidence' (float between 0 and 100): How confident you are about this suggestion
- 'explanation' (string): A detailed explanation of why this category was chosen, including analysis of the product name, description, and other relevant information.""",
IMPORTANT: Your response MUST be a JSON object with a 'products' array containing EXACTLY ONE object for EACH product in the input list.
Example format for a list of 2 products:
{
"products": [
{
"odoo_id": 1,
"category_id": 454,
"confidence": 85.0,
"explanation": "The product is categorized as safety equipment because..."
},
{
"odoo_id": 2,
"category_id": 448,
"confidence": 90.0,
"explanation": "This product belongs to vacuum systems because..."
}
]
}
Requirements:
1. Response must be a single JSON object with a 'products' array
2. You MUST return exactly one object in the products array for each product in the input list
3. Each object must have exactly these fields:
- odoo_id (integer): The index of the product in the input list (starting at 1)
- category_id (integer): The ID of the most appropriate category
- confidence (float between 0 and 100): How confident you are about this suggestion
- explanation (string): A detailed explanation of why this category was chosen
4. Do not add any other fields outside of the products array
5. Do not add any markdown formatting or code blocks
6. If you're not sure about a product's category, still provide a suggestion with lower confidence""",
'format': 'json'
}
return json.dumps(message)
@ -100,19 +129,32 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
def _process_bot_response(self, values, response):
"""Process the bot response to extract the suggested category."""
try:
results = json.loads(response)
# Parse the response
if isinstance(response, str):
response_data = json.loads(response)
else:
response_data = response # Already parsed JSON
# Extract products list
if isinstance(response_data, dict) and 'products' in response_data:
results = response_data['products']
elif isinstance(response_data, list):
results = response_data
else:
raise ValueError("La réponse ne contient pas de liste de produits valide")
if not isinstance(results, list):
raise ValueError("La réponse n'est pas une liste JSON valide")
for result in results:
category_id = result.get('category_id')
confidence = result.get('confidence', 0.0)
product_id = result.get('odoo_id')
temp_id = result.get('odoo_id')
if not category_id:
raise ValueError("No category ID in response")
if not product_id:
if not temp_id:
raise ValueError("No product ID in response")
# Vérifier que la catégorie existe
@ -120,10 +162,10 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
if not category:
raise ValueError(f"Category {category_id} not found")
# Trouver le produit concerné
product = self.filtered(lambda p: p.id == product_id)
# Trouver le produit concerné directement par son ID
product = self.filtered(lambda p: p.id == temp_id)
if not product:
raise ValueError(f"Product {product_id} not found in selection")
raise ValueError(f"Product {temp_id} not found in selection")
# Mettre à jour les valeurs pour ce produit
product.write({
@ -134,7 +176,7 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
# Créer l'historique
self.env['product.category.suggestion.history'].create({
'product_id': product_id,
'product_id': product.id,
'suggested_category_id': category_id,
'suggestion_confidence': confidence,
'input_data': json.dumps(result),
@ -147,10 +189,43 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
return values
def _calculate_optimal_batch_size(self, products, max_chars=2048):
"""Calculate the optimal batch size based on message length limit.
Args:
products: recordset of products to process
max_chars: maximum number of characters allowed (default: 2048)
Returns:
int: optimal number of products to process in one batch
"""
# Test with a small batch first
test_size = 5
test_products = products[:test_size]
test_message = self._generate_bot_message(test_products, {})
# Calculate average characters per product
chars_per_product = len(test_message) / test_size
# Calculate optimal batch size with 10% safety margin
optimal_size = int((max_chars * 0.9) / chars_per_product)
# Ensure batch size is at least 1 and no more than 800 (existing limit)
return max(1, min(optimal_size, 800))
def action_suggest_category(self):
"""Request category suggestions from AI."""
if len(self) > 800:
raise UserError(_("For performance reasons, you cannot analyze more than 800 products at once."))
# Get company settings
company = self.env.company
# Dédoublonner les produits
unique_products = self.filtered(lambda p: p.id).sorted(lambda p: p.id)
# Get max products from company settings
max_products = company.openwebui_max_products
if len(unique_products) > max_products:
raise UserError(_("For performance reasons, you cannot analyze more than %d products at once.") % max_products)
# Get company settings
company = self.env.company
@ -161,12 +236,15 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
if not model:
raise UserError(_("No default OpenWebUI model configured. Please configure it in company settings."))
# Process products in batches
batch_size = company.openwebui_products_per_request
successful_products = self.env['product.template']
# Calculate optimal batch size
batch_size = self._calculate_optimal_batch_size(unique_products)
_logger.info(f"Processing products with calculated batch size: {batch_size}")
for i in range(0, len(self), batch_size):
batch = self[i:i + batch_size]
successful_products = self.env['product.template']
products_to_process = unique_products
for i in range(0, len(products_to_process), batch_size):
batch = products_to_process[i:i + batch_size]
# Créer une nouvelle transaction pour ce batch
with self.env.cr.savepoint():

View file

@ -0,0 +1 @@
../.repos/bemade-addons/openwebui_integration

View file

@ -0,0 +1 @@
../.repos/bemade-addons/openwebui_integration_chat

View file

@ -53,9 +53,10 @@
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_tree_view"/>
<field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="action">action_suggest_category_multi</attribute>
</xpath>
<field name="categ_id" position="after">
<field name="suggested_category_id" optional="show"/>
<field name="suggestion_confidence" widget="percentage" optional="show"/>
</field>
</field>
</record>