fuck my life with ollama
This commit is contained in:
parent
a3b4d3ed39
commit
b95ca97022
13 changed files with 2253 additions and 158 deletions
1627
openwebui_integration/Docs/OllamaAPI.md
Normal file
1627
openwebui_integration/Docs/OllamaAPI.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
{
|
{
|
||||||
'name': 'OpenWebUI Integration',
|
'name': 'OpenWebUI Integration',
|
||||||
'version': '18.0.1.0.1',
|
'version': '18.0.1.1.0',
|
||||||
'summary': 'Core integration with OpenWebUI',
|
'summary': 'Core integration with OpenWebUI',
|
||||||
'sequence': 10,
|
'sequence': 10,
|
||||||
'description': """
|
'description': """
|
||||||
|
|
@ -30,4 +30,5 @@ Features:
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
'license': 'LGPL-3',
|
'license': 'LGPL-3',
|
||||||
'i18n': True,
|
'i18n': True,
|
||||||
|
'post_init_hook': 'post_init_hook',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
openwebui_integration/hooks.py
Normal file
9
openwebui_integration/hooks.py
Normal 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;
|
||||||
|
""")
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from . import pre-migration
|
|
||||||
|
|
@ -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
|
|
||||||
""")
|
|
||||||
|
|
@ -75,13 +75,16 @@ class OpenWebUIBotMixin(models.AbstractModel):
|
||||||
# Send the message to the model
|
# Send the message to the model
|
||||||
_logger.info('Attempt %d/%d: Sending message to model (timeout=%ds)',
|
_logger.info('Attempt %d/%d: Sending message to model (timeout=%ds)',
|
||||||
attempt + 1, max_retries, timeout)
|
attempt + 1, max_retries, timeout)
|
||||||
|
_logger.info('Sending message to model: %r', message)
|
||||||
response = model.send_message(message=message, timeout=timeout)
|
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
|
# Si la réponse contient une erreur de connexion, on la traite comme une exception
|
||||||
if isinstance(response, str) and "Connection error" in response:
|
if isinstance(response, str) and "Connection error" in response:
|
||||||
|
_logger.error('Connection error in response: %r', response)
|
||||||
raise ConnectionError(response)
|
raise ConnectionError(response)
|
||||||
|
|
||||||
_logger.info('Received response from model: %r', response)
|
_logger.info('Processed response from model: %r', response)
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
raise ValueError("Empty response from model")
|
raise ValueError("Empty response from model")
|
||||||
|
|
@ -89,18 +92,62 @@ class OpenWebUIBotMixin(models.AbstractModel):
|
||||||
if not isinstance(response, str):
|
if not isinstance(response, str):
|
||||||
raise ValueError(f"Invalid response type: {type(response)}")
|
raise ValueError(f"Invalid response type: {type(response)}")
|
||||||
|
|
||||||
# Clean up the response
|
# Debug de la réponse brute
|
||||||
# Supprimer les blocs de code markdown
|
_logger.info('Raw response from model: %r', response)
|
||||||
response = re.sub(r'```json\n|\n```', '', response.strip())
|
|
||||||
_logger.debug('Response after markdown cleanup: %r', response)
|
|
||||||
|
|
||||||
try:
|
# Clean up the response
|
||||||
# Essayer de parser directement la réponse nettoyée
|
# Supprimer les blocs de code markdown et autres caractères problématiques
|
||||||
parsed_response = json.loads(response)
|
response = re.sub(r'```json\n|\n```', '', response.strip())
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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
|
clean_response = response
|
||||||
except json.JSONDecodeError as e:
|
# Si le parsing initial a échoué, essayer d'extraire une liste JSON
|
||||||
_logger.warning('Failed to parse response directly: %s', e)
|
if last_json_error is not None:
|
||||||
# Si échec, chercher une liste JSON dans la réponse
|
_logger.warning('Failed to parse response directly, trying to extract JSON list')
|
||||||
start = response.find('[')
|
start = response.find('[')
|
||||||
end = response.rfind(']')
|
end = response.rfind(']')
|
||||||
|
|
||||||
|
|
@ -111,11 +158,30 @@ class OpenWebUIBotMixin(models.AbstractModel):
|
||||||
clean_response = response[start:end + 1]
|
clean_response = response[start:end + 1]
|
||||||
_logger.debug('Extracted JSON list: %r', clean_response)
|
_logger.debug('Extracted JSON list: %r', clean_response)
|
||||||
|
|
||||||
try:
|
# Nouveau cycle de retry pour le JSON extrait
|
||||||
parsed_response = json.loads(clean_response)
|
for json_attempt in range(max_json_retries):
|
||||||
except json.JSONDecodeError as e:
|
try:
|
||||||
_logger.error('Failed to parse extracted JSON: %s', e)
|
parsed_response = json.loads(clean_response)
|
||||||
raise ValueError("Invalid JSON list in 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):
|
if not isinstance(parsed_response, list):
|
||||||
_logger.error('Response is not a list: %r', parsed_response)
|
_logger.error('Response is not a list: %r', parsed_response)
|
||||||
|
|
|
||||||
|
|
@ -115,32 +115,108 @@ class OpenWebUIModel(models.Model):
|
||||||
str: The model's response or error message
|
str: The model's response or error message
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
company = self.env.company
|
||||||
|
|
||||||
# Initialize messages list with history if provided
|
# Base data structure
|
||||||
messages = []
|
if company.ai_provider == 'ollama':
|
||||||
if message_history:
|
# Format for Ollama API
|
||||||
messages.extend(message_history)
|
data = {
|
||||||
|
'model': self.identifier,
|
||||||
|
'stream': False, # We want a single response
|
||||||
|
'options': {
|
||||||
|
'num_ctx': company.openwebui_context_size,
|
||||||
|
'temperature': 0.7 # Default temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Add the current message
|
# Handle message history and current message
|
||||||
messages.append({'role': 'user', 'content': 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'
|
||||||
|
|
||||||
data = {
|
if not 'endpoint' in locals():
|
||||||
'model': self.identifier,
|
endpoint = 'api/chat'
|
||||||
'messages': messages,
|
|
||||||
}
|
|
||||||
|
|
||||||
if context:
|
else: # openwebui
|
||||||
data['context'] = context
|
# Format for OpenWebUI API
|
||||||
if instructions:
|
data = {
|
||||||
data['system'] = instructions
|
'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'
|
||||||
|
|
||||||
|
_logger.info('Sending request to %s with data: %r', endpoint, data)
|
||||||
|
success, result = self._make_request(endpoint, method='POST', data=data, timeout=timeout)
|
||||||
|
|
||||||
success, result = self._make_request('chat/completions', method='POST', data=data, timeout=timeout)
|
|
||||||
if not success:
|
if not success:
|
||||||
|
_logger.error('Request failed: %s', result)
|
||||||
return f"Error: {result}"
|
return f"Error: {result}"
|
||||||
|
|
||||||
|
_logger.info('Raw API response: %r', result)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return result['choices'][0]['message']['content']
|
# Handle Ollama response format
|
||||||
except (KeyError, IndexError) as e:
|
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)}"
|
return f"Error processing response: {str(e)}"
|
||||||
|
|
||||||
def cleanup_temp_models(self):
|
def cleanup_temp_models(self):
|
||||||
|
|
@ -217,7 +293,32 @@ class OpenWebUIModel(models.Model):
|
||||||
return False, "OpenWebUI is not enabled"
|
return False, "OpenWebUI is not enabled"
|
||||||
|
|
||||||
try:
|
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 = {
|
headers = {
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
}
|
}
|
||||||
|
|
@ -240,10 +341,46 @@ class OpenWebUIModel(models.Model):
|
||||||
elif method in ['POST', 'PUT', 'PATCH']:
|
elif method in ['POST', 'PUT', 'PATCH']:
|
||||||
kwargs['json'] = data
|
kwargs['json'] = data
|
||||||
|
|
||||||
|
# Send the request
|
||||||
response = requests.request(method=method, url=url, **kwargs)
|
response = requests.request(method=method, url=url, **kwargs)
|
||||||
response.raise_for_status()
|
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:
|
except requests.exceptions.SSLError:
|
||||||
return False, "SSL/TLS verification failed"
|
return False, "SSL/TLS verification failed"
|
||||||
|
|
@ -260,7 +397,7 @@ class OpenWebUIModel(models.Model):
|
||||||
def test_connection(self):
|
def test_connection(self):
|
||||||
"""Test the connection to the OpenWebUI API.
|
"""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:
|
to verify that:
|
||||||
1. The API URL is accessible
|
1. The API URL is accessible
|
||||||
2. The credentials are valid
|
2. The credentials are valid
|
||||||
|
|
@ -274,37 +411,46 @@ class OpenWebUIModel(models.Model):
|
||||||
Note:
|
Note:
|
||||||
Uses a reduced timeout of 5 seconds to avoid long waits.
|
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
|
@api.model
|
||||||
def get_available_models(self):
|
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.
|
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.
|
If it fails, it returns a default list of common models.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: A list of tuples (id, name) of available models.
|
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:
|
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
|
- Returns DEFAULT_MODELS in case of error or unexpected format
|
||||||
- Errors are logged but don't interrupt execution
|
- 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:
|
if not success:
|
||||||
_logger.error(f"Error fetching models: {result}")
|
# Try OpenWebUI endpoint as fallback
|
||||||
return DEFAULT_MODELS
|
success, result = self._make_request('models')
|
||||||
|
if not success:
|
||||||
|
_logger.error(f"Error fetching models: {result}")
|
||||||
|
return DEFAULT_MODELS
|
||||||
|
|
||||||
try:
|
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]
|
return [(model['id'], model.get('name', model['id'])) for model in result]
|
||||||
elif isinstance(result, dict) and 'data' in result:
|
elif isinstance(result, dict) and 'data' in result:
|
||||||
return [(model['id'], model.get('name', model['id'])) for model in result['data']]
|
return [(model['id'], model.get('name', model['id'])) for model in result['data']]
|
||||||
else:
|
else:
|
||||||
_logger.warning("Unexpected response format from OpenWebUI API")
|
_logger.warning("Unexpected response format from API")
|
||||||
return DEFAULT_MODELS
|
return DEFAULT_MODELS
|
||||||
except (KeyError, TypeError, AttributeError) as e:
|
except (KeyError, TypeError, AttributeError) as e:
|
||||||
_logger.error(f"Error processing models response: {str(e)}")
|
_logger.error(f"Error processing models response: {str(e)}")
|
||||||
|
|
@ -327,7 +473,7 @@ class OpenWebUIModel(models.Model):
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _sync_models(self):
|
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:
|
This method performs the actual synchronization work:
|
||||||
1. Cleans up temporary models
|
1. Cleans up temporary models
|
||||||
|
|
@ -342,27 +488,47 @@ class OpenWebUIModel(models.Model):
|
||||||
Note:
|
Note:
|
||||||
This is an internal method called by sync_models().
|
This is an internal method called by sync_models().
|
||||||
It should not be called directly unless you need fine-grained control.
|
It should not be called directly unless you need fine-grained control.
|
||||||
|
Supports both Ollama and OpenWebUI API formats.
|
||||||
"""
|
"""
|
||||||
# First clean up temporary models
|
# First clean up temporary models
|
||||||
self.cleanup_temp_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:
|
if not success:
|
||||||
return False, result
|
# Try OpenWebUI endpoint as fallback
|
||||||
|
success, result = self._make_request('models')
|
||||||
|
if not success:
|
||||||
|
return False, result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
models_data = result if isinstance(result, list) else result.get('data', [])
|
# Handle Ollama response format
|
||||||
for model_data in models_data:
|
if isinstance(result, dict) and 'models' in result:
|
||||||
existing = self.search([('identifier', '=', model_data['id'])])
|
models_data = result['models']
|
||||||
if not existing:
|
for model_data in models_data:
|
||||||
self.with_context(sync_models=True).create({
|
existing = self.search([('identifier', '=', model_data['name'])])
|
||||||
'name': model_data.get('name', model_data['id']),
|
if not existing:
|
||||||
'identifier': model_data['id'],
|
self.with_context(sync_models=True).create({
|
||||||
'description': model_data.get('description', ''),
|
'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
|
return True, None
|
||||||
except (KeyError, TypeError) as e:
|
except (KeyError, TypeError) as e:
|
||||||
_logger.error(f"Error processing model data: {str(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)}"
|
return False, f"Invalid model data format: {str(e)}"
|
||||||
except odoo.exceptions.AccessError as e:
|
except odoo.exceptions.AccessError as e:
|
||||||
_logger.error(f"Access error while creating model: {str(e)}")
|
_logger.error(f"Access error while creating model: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -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.
|
# 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
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class ResCompany(models.Model):
|
class ResCompany(models.Model):
|
||||||
_inherit = 'res.company'
|
_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(
|
openwebui_enabled = fields.Boolean(
|
||||||
string='Enable OpenWebUI',
|
string='Enable OpenWebUI',
|
||||||
default=False,
|
default=False,
|
||||||
|
|
@ -41,6 +62,54 @@ class ResCompany(models.Model):
|
||||||
help="Maximum wait time for API calls"
|
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(
|
openwebui_products_per_request = fields.Integer(
|
||||||
string='Products per Request',
|
string='Products per Request',
|
||||||
default=10,
|
default=10,
|
||||||
|
|
@ -60,6 +129,12 @@ class ResCompany(models.Model):
|
||||||
help="Default OpenWebUI model to use for AI requests"
|
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):
|
def test_openwebui_connection(self):
|
||||||
"""Test the connection to OpenWebUI API.
|
"""Test the connection to OpenWebUI API.
|
||||||
|
|
||||||
|
|
@ -108,54 +183,107 @@ class ResCompany(models.Model):
|
||||||
}
|
}
|
||||||
|
|
||||||
def refresh_model_list(self):
|
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:
|
This method performs the following operations based on the selected AI provider:
|
||||||
1. Verifies that OpenWebUI integration is enabled for the company
|
|
||||||
|
For OpenWebUI:
|
||||||
|
1. Verifies that OpenWebUI integration is enabled
|
||||||
2. Connects to the OpenWebUI API to fetch the latest model list
|
2. Connects to the OpenWebUI API to fetch the latest model list
|
||||||
3. Updates the local database:
|
3. Updates the local database with model information
|
||||||
- Creates records for new models
|
|
||||||
- Updates existing model information
|
|
||||||
- Archives models that are no longer available
|
|
||||||
|
|
||||||
Technical Details:
|
For Ollama:
|
||||||
- Uses the sync_models context to trigger a full synchronization
|
1. Connects to the Ollama server
|
||||||
- Performs all operations in a single transaction
|
2. Fetches available models using the Ollama API
|
||||||
- Handles API connection errors gracefully
|
3. Updates the local database with model information
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: An action dictionary with the following structure:
|
dict: A notification action with the sync results
|
||||||
{
|
|
||||||
'type': 'ir.actions.client',
|
|
||||||
'tag': 'display_notification',
|
|
||||||
'params': {
|
|
||||||
'title': str,
|
|
||||||
'message': str,
|
|
||||||
'sticky': bool,
|
|
||||||
'type': str,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
UserError: In the following cases:
|
UserError: If there are connection or synchronization issues
|
||||||
- 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!'
|
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
||||||
if not self.openwebui_enabled:
|
if self.ai_provider == 'ollama':
|
||||||
raise UserError(_("OpenWebUI is not enabled for this company."))
|
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))
|
||||||
|
|
||||||
Model = self.env['openwebui.model'].with_context(sync_models=True)
|
def _refresh_ollama_models(self):
|
||||||
success, result = Model._sync_models()
|
"""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:
|
if not success:
|
||||||
raise UserError(_("Failed to refresh models: %s") % result)
|
raise UserError(_("Failed to refresh models: %s") % result)
|
||||||
|
|
||||||
|
|
@ -164,7 +292,7 @@ class ResCompany(models.Model):
|
||||||
'tag': 'display_notification',
|
'tag': 'display_notification',
|
||||||
'params': {
|
'params': {
|
||||||
'title': _('Success'),
|
'title': _('Success'),
|
||||||
'message': _('Models list has been refreshed successfully!'),
|
'message': _('Successfully synchronized OpenWebUI models'),
|
||||||
'sticky': False,
|
'sticky': False,
|
||||||
'type': 'success',
|
'type': 'success',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,29 @@
|
||||||
<field name="inherit_id" ref="base.view_company_form"/>
|
<field name="inherit_id" ref="base.view_company_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//notebook" position="inside">
|
<xpath expr="//notebook" position="inside">
|
||||||
<page string="OpenWebUI" name="openwebui">
|
<page string="AI Configuration" name="ai_config">
|
||||||
<group>
|
<group>
|
||||||
|
<group string="Provider Selection">
|
||||||
|
<field name="ai_provider"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Ollama Configuration -->
|
||||||
|
<group string="Ollama Configuration" invisible="ai_provider != 'ollama'">
|
||||||
<group>
|
<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"
|
<field name="openwebui_api_url"
|
||||||
invisible="not openwebui_enabled"
|
invisible="not openwebui_enabled"
|
||||||
required="openwebui_enabled"
|
required="openwebui_enabled"
|
||||||
|
|
@ -24,6 +43,9 @@
|
||||||
invisible="not openwebui_enabled"/>
|
invisible="not openwebui_enabled"/>
|
||||||
<field name="openwebui_timeout"
|
<field name="openwebui_timeout"
|
||||||
invisible="not openwebui_enabled"/>
|
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"
|
<field name="openwebui_products_per_request"
|
||||||
invisible="not openwebui_enabled"
|
invisible="not openwebui_enabled"
|
||||||
help="Number of products to process in a single API request"/>
|
help="Number of products to process in a single API request"/>
|
||||||
|
|
@ -34,6 +56,19 @@
|
||||||
invisible="not openwebui_enabled"/>
|
invisible="not openwebui_enabled"/>
|
||||||
</group>
|
</group>
|
||||||
</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">
|
<group string="Models Configuration" invisible="not openwebui_enabled">
|
||||||
<field name="openwebui_default_model_id"
|
<field name="openwebui_default_model_id"
|
||||||
options="{'no_create': True, 'no_open': True}"
|
options="{'no_create': True, 'no_open': True}"
|
||||||
|
|
|
||||||
|
|
@ -55,21 +55,24 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
||||||
def _generate_bot_message(self, records, values, command=None):
|
def _generate_bot_message(self, records, values, command=None):
|
||||||
"""Generates the message to send to the bot to get category suggestions."""
|
"""Generates the message to send to the bot to get category suggestions."""
|
||||||
# Préparer les données des produits
|
# Préparer les données des produits
|
||||||
products_data = [{
|
# Prepare product data for AI analysis
|
||||||
'odoo_id': record.id, # ID interne Odoo
|
products_data = []
|
||||||
'name': record.name,
|
for record in records:
|
||||||
'description': record.description or '',
|
products_data.append({
|
||||||
'description_sale': record.description_sale or '',
|
'odoo_id': record.id, # Unique Odoo product ID
|
||||||
'default_code': record.default_code or '',
|
'name': record.name,
|
||||||
'current_category': record.categ_id.display_name,
|
'description': record.description or '',
|
||||||
'sellers': [
|
'description_sale': record.description_sale or '',
|
||||||
{
|
'default_code': record.default_code or '',
|
||||||
'name': seller.partner_id.display_name,
|
'current_category': record.categ_id.display_name,
|
||||||
'product_code': seller.product_code or '',
|
'sellers': [
|
||||||
'product_name': seller.product_name or ''
|
{
|
||||||
} for seller in record.seller_ids
|
'name': seller.partner_id.display_name,
|
||||||
]
|
'product_code': seller.product_code or '',
|
||||||
} for record in records]
|
'product_name': seller.product_name or ''
|
||||||
|
} for seller in record.seller_ids
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
# Récupérer toutes les catégories disponibles
|
# Récupérer toutes les catégories disponibles
|
||||||
Category = self.env['product.category']
|
Category = self.env['product.category']
|
||||||
|
|
@ -88,11 +91,37 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
||||||
'available_categories': available_categories,
|
'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.
|
'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.
|
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
|
IMPORTANT: Your response MUST be a JSON object with a 'products' array containing EXACTLY ONE object for EACH product in the input list.
|
||||||
- 'category_id' (integer): The ID of the most appropriate category
|
Example format for a list of 2 products:
|
||||||
- '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.""",
|
"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'
|
'format': 'json'
|
||||||
}
|
}
|
||||||
return json.dumps(message)
|
return json.dumps(message)
|
||||||
|
|
@ -100,19 +129,32 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
||||||
def _process_bot_response(self, values, response):
|
def _process_bot_response(self, values, response):
|
||||||
"""Process the bot response to extract the suggested category."""
|
"""Process the bot response to extract the suggested category."""
|
||||||
try:
|
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):
|
if not isinstance(results, list):
|
||||||
raise ValueError("La réponse n'est pas une liste JSON valide")
|
raise ValueError("La réponse n'est pas une liste JSON valide")
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
category_id = result.get('category_id')
|
category_id = result.get('category_id')
|
||||||
confidence = result.get('confidence', 0.0)
|
confidence = result.get('confidence', 0.0)
|
||||||
product_id = result.get('odoo_id')
|
temp_id = result.get('odoo_id')
|
||||||
|
|
||||||
if not category_id:
|
if not category_id:
|
||||||
raise ValueError("No category ID in response")
|
raise ValueError("No category ID in response")
|
||||||
|
|
||||||
if not product_id:
|
if not temp_id:
|
||||||
raise ValueError("No product ID in response")
|
raise ValueError("No product ID in response")
|
||||||
|
|
||||||
# Vérifier que la catégorie existe
|
# Vérifier que la catégorie existe
|
||||||
|
|
@ -120,10 +162,10 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
||||||
if not category:
|
if not category:
|
||||||
raise ValueError(f"Category {category_id} not found")
|
raise ValueError(f"Category {category_id} not found")
|
||||||
|
|
||||||
# Trouver le produit concerné
|
# Trouver le produit concerné directement par son ID
|
||||||
product = self.filtered(lambda p: p.id == product_id)
|
product = self.filtered(lambda p: p.id == temp_id)
|
||||||
if not product:
|
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
|
# Mettre à jour les valeurs pour ce produit
|
||||||
product.write({
|
product.write({
|
||||||
|
|
@ -134,7 +176,7 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
||||||
|
|
||||||
# Créer l'historique
|
# Créer l'historique
|
||||||
self.env['product.category.suggestion.history'].create({
|
self.env['product.category.suggestion.history'].create({
|
||||||
'product_id': product_id,
|
'product_id': product.id,
|
||||||
'suggested_category_id': category_id,
|
'suggested_category_id': category_id,
|
||||||
'suggestion_confidence': confidence,
|
'suggestion_confidence': confidence,
|
||||||
'input_data': json.dumps(result),
|
'input_data': json.dumps(result),
|
||||||
|
|
@ -147,10 +189,43 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
||||||
|
|
||||||
return values
|
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):
|
def action_suggest_category(self):
|
||||||
"""Request category suggestions from AI."""
|
"""Request category suggestions from AI."""
|
||||||
if len(self) > 800:
|
# Get company settings
|
||||||
raise UserError(_("For performance reasons, you cannot analyze more than 800 products at once."))
|
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
|
# Get company settings
|
||||||
company = self.env.company
|
company = self.env.company
|
||||||
|
|
@ -161,12 +236,15 @@ class ProductTemplate(models.Model, OpenWebUIBotMixin):
|
||||||
if not model:
|
if not model:
|
||||||
raise UserError(_("No default OpenWebUI model configured. Please configure it in company settings."))
|
raise UserError(_("No default OpenWebUI model configured. Please configure it in company settings."))
|
||||||
|
|
||||||
# Process products in batches
|
# Calculate optimal batch size
|
||||||
batch_size = company.openwebui_products_per_request
|
batch_size = self._calculate_optimal_batch_size(unique_products)
|
||||||
successful_products = self.env['product.template']
|
_logger.info(f"Processing products with calculated batch size: {batch_size}")
|
||||||
|
|
||||||
for i in range(0, len(self), batch_size):
|
successful_products = self.env['product.template']
|
||||||
batch = self[i:i + batch_size]
|
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
|
# Créer une nouvelle transaction pour ce batch
|
||||||
with self.env.cr.savepoint():
|
with self.env.cr.savepoint():
|
||||||
|
|
|
||||||
1
openwebui_integration_product/openwebui_integration
Symbolic link
1
openwebui_integration_product/openwebui_integration
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../.repos/bemade-addons/openwebui_integration
|
||||||
1
openwebui_integration_product/openwebui_integration_chat
Symbolic link
1
openwebui_integration_product/openwebui_integration_chat
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../.repos/bemade-addons/openwebui_integration_chat
|
||||||
|
|
@ -53,9 +53,10 @@
|
||||||
<field name="model">product.template</field>
|
<field name="model">product.template</field>
|
||||||
<field name="inherit_id" ref="product.product_template_tree_view"/>
|
<field name="inherit_id" ref="product.product_template_tree_view"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//list" position="attributes">
|
<field name="categ_id" position="after">
|
||||||
<attribute name="action">action_suggest_category_multi</attribute>
|
<field name="suggested_category_id" optional="show"/>
|
||||||
</xpath>
|
<field name="suggestion_confidence" widget="percentage" optional="show"/>
|
||||||
|
</field>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue