Files
enterprise-addons/ai/models/models.py
T

272 lines
13 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import datetime
import pytz
import json
from odoo import models
from odoo.api import NewId
from odoo.exceptions import AccessError
from odoo.tools import OrderedSet
from odoo.tools.mail import html_to_inner_content
from odoo.tools.misc import formatLang
from odoo.tools.mimetypes import guess_mimetype
AI_SUPPORTED_IMG_TYPES = {'png', 'jpg', 'jpeg', 'webp', 'gif'}
class Model(models.AbstractModel):
_inherit = 'base'
def _ai_truncate(self, value, size=60):
# Limit the size of the field we can, to try to limit prompt injection...
if not isinstance(value, str) or len(value) < size:
return value
return value[:max(0, size - 3)] + "..."
def _ai_field_names_to_truncate(self):
return ('name', 'display_name')
def _ai_serialize_fields_data(self):
fields_info = self.fields_get()
result = {}
for field_name, field_attrs in fields_info.items():
field_type = field_attrs["type"]
try:
field_value = self[field_name]
if field_type == 'char' and field_name in self._ai_field_names_to_truncate():
field_value = self._ai_truncate(field_value)
# Handle relational fields
if field_type == "many2one":
result[field_name] = (
self._ai_truncate(field_value.display_name) if field_value else None
)
elif field_type in ["one2many", "many2many"]:
linked_records = self.env[field_value._name].browse(field_value.ids)
if (
len(linked_records) > 50
): # there have been cases were too many linked records have flooded the context - avoid that by filtering them out
continue
else:
result[field_name] = [
self._ai_truncate(record.display_name) for record in linked_records
]
elif field_type == "binary":
continue # we don't include binary fields in the record info JSON
else:
# Handle basic field types (dates, etc.)
if isinstance(field_value, datetime.datetime):
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
result[field_name] = (
field_value.astimezone(user_tz).strftime(
"%Y-%m-%d %H:%M:%S"
)
if field_value
else None
)
elif isinstance(field_value, models.BaseModel):
# Handle unexpected recordset returns (shouldn't happen for non-relational fields)
result[field_name] = field_value.ids
else:
result[field_name] = field_value
except AccessError: # if the user doesn't have access to a field, don't include it in the AI's context
continue
return json.dumps(result, default=str)
def _ai_initialise_context(
self, caller_component, text_selection=None, front_end_info=None
):
context = []
# If we have record info available from the front-end, pass it to the model's context
if caller_component in ["html_field_record", "chatter_ai_button"]:
context.append(f"You were called within an Odoo {self._name} record. Your answers should take the record's details into account. The following JSON contains all of the record's details: {front_end_info}")
# If we don't have record info from the front-end and it's required, fetch the record information and pass it to the model's context
if caller_component == "mail_composer":
context.append(f"You were called within an Odoo {self._name} record. Your answers should take the record's details into account. The following JSON contains all of the records details: {self._ai_serialize_fields_data()}")
# Add some additional details for some special cases and finish the context by the "first" message sent by the assistant
if caller_component == "html_field_text_select":
context.append(f"The text that you will be rewritting is the following: {text_selection}")
else:
context.append("ALWAYS FORMAT YOUR ANSWERS USING MARKDOWN, AVOID USING HTML. Don't use unecessary formatting like code blocks if not needed.")
return context
################
# Extensions #
################
def _ai_read(self, fnames, files_dict):
"""Retrieve and format field values for LLM processing.
If no field names are given, return the display name.
This method can be overridden on any model that requires sending more than just the display
name when its records are included in a prompt (such as attachments).
Files are handled separately, as they must be sent independently to LLMs.
:param fnames: list of field names to read and format
:param files_dict: dict mapping file checksums to metadata dicts each with keys:
'mimetype', 'value', and 'file_ref'
:return: (vals_list, files_dict) where vals_list is a list of dicts mapping field names to
their formatted values with one dictionary per record, and files_dict is a dict mapping
file checksums to metadata dicts each with keys 'mimetype', 'value' and 'fileref'
"""
if not fnames:
fnames = ['display_name'] # by default, send display names
vals_list = self.read(fnames, load=None)
for fname in fnames:
field = self._fields.get(fname)
if field.type in ('binary', 'image'):
if field.attachment and self.ids: # attachment is not created yet in quick creation
attachments = self.env['ir.attachment'].search([
('res_model', '=', self._name),
('res_field', '=', fname),
('res_id', 'in', self.ids) # ._origin?
])
__, files_dict = attachments._ai_read(None, files_dict) # populate file dict
for vals in vals_list:
if not vals[fname]:
continue
checksum = self.env['ir.attachment']._compute_checksum(vals[fname])
if checksum not in files_dict:
raw = base64.b64decode(vals[fname])
mimetype = guess_mimetype(raw)
extension = mimetype.split("/")[-1]
file_ref = f'<file_#{len(files_dict) + 1}>'
if extension in (*AI_SUPPORTED_IMG_TYPES, 'pdf'):
# todo: keep 5 pages max and resize images
value = vals[fname].decode()
else:
try:
value = self.env['ir.attachment']._index(vals[fname], mimetype, checksum=checksum)
except TypeError:
value = self.env['ir.attachment']._index(vals[fname], mimetype)
files_dict[checksum] = {
'mimetype': mimetype,
'value': value,
'file_ref': file_ref,
}
vals[fname] = files_dict[checksum]['file_ref']
elif field.type in ('date', 'datetime'):
for vals in vals_list:
vals[fname] = field.to_string(vals[fname])
elif field.type == 'html':
for vals in vals_list:
vals[fname] = html_to_inner_content(vals[fname])
elif field.type in ('many2many', 'many2one', 'many2one_reference', 'one2many', 'reference'):
# can't use result of read because we might have temporary records (with NewId), so
# the ids won't be the ids we expect (origin ids or none for virtual records)
vals_by_ids = {vals['id']: vals for vals in vals_list}
for record in self:
record_vals = vals_by_ids[record.id]
co_records = record[fname]
if not co_records:
record_vals[fname] = False # keep falsy values consistent for the LLM
if field.type == 'many2one_reference':
record_vals[fname] = {'model': model, 'ids': record_vals[fname]} if (model := record[field.model_field]) else False
else:
record_vals[fname] = {'model': co_records._name, 'ids': co_records._ids}
elif field.type == 'monetary':
currency_field = field.get_currency_field(self)
if currency_field:
currency = self[currency_field]
for vals in vals_list:
vals[fname] = formatLang(self.env, vals[fname], currency_obj=currency)
elif field.type == 'char' and field.name in self._ai_field_names_to_truncate():
for vals in vals_list:
vals[fname] = self._ai_truncate(vals[fname])
return vals_list, files_dict
def _get_ai_context(self, field_paths):
""" Get the json-encoded context dict for a record given a list of field paths.
The context dict is a mini-orm snapshot with values formatted for LLM usage.
It is a dictionary of the form:
.. code-block:: python
{
"model_A": [
{
"id": 1,
"field_A": "val_1",
"field_B": {"model": "model_B", "ids": [3]},
},
{
"id": 2,
"field_A": "val_2",
"field_B": {"model": "model_B", "ids": [4]},
}
],
"model_B": [
{
"id": 3,
"field_C": "val_3"
},
{
"id": 4,
"field_C": "val_4"
}
]
}
"""
self.ensure_one()
models = {}
def _map_to_models(records, path):
model = records._name
ids = OrderedSet(records._ids)
if model not in models:
models[model] = {'fields': OrderedSet(), 'ids': ids}
else:
models[model]['ids'] |= ids
if not path:
return
fname = path[0]
field = records._fields.get(fname)
if not field:
return
if field.type in ('many2many', 'many2one', 'one2many'):
_map_to_models(records[fname], path[1:])
elif field.type == 'reference':
for record in records:
if record[fname]:
_map_to_models(record[fname], path[1:])
elif field.type == 'many2one_reference':
for record in records:
if (ref_model := record[field.model_field]) and (ref_id := record[fname]):
_map_to_models(self.env[ref_model].browse(ref_id), path[1:])
models[model]['fields'].add(fname)
# get a mapping {model: {fields, ids}} to know which fields to read on which records
for path in field_paths:
_map_to_models(self, path.split("."))
snapshot = {}
files_dict = {} # files are sent separately to LLMs
for model, info in models.items():
records = self.env[model].browse(info['ids'])
snapshot[model], files_dict = records._ai_read(list(info['fields']), files_dict)
def _ai_context_json_default(obj):
"""NewId is not json serializable, use its string representation"""
if isinstance(obj, NewId):
return obj.origin or str(obj)
return obj
return json.dumps(snapshot, default=_ai_context_json_default, ensure_ascii=False, indent=2), list(files_dict.values())
def _ai_format_records(self):
"""Format what will be in the prompt when we inserted records.
It needs to return a dict which keys are the records ids, and
the value of the dict can be anything.
"""
return {record.id: record.display_name for record in self}