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

548 lines
20 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import requests
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.addons.mail.tools import link_preview
from odoo.exceptions import AccessError
from ..utils.html_extractor import HTMLExtractor
class AIAgentSource(models.Model):
_name = 'ai.agent.source'
_description = 'AI Agent Source'
_order = 'name'
name = fields.Char(string="Name")
agent_id = fields.Many2one('ai.agent', string="Agent", index=True, required=True)
type = fields.Selection([('url', 'URL'), ('binary', 'File')],
default='binary', string='Type', required=True, readonly=True)
status = fields.Selection(string="Status", selection=[('processing', 'Processing'), ('indexed', 'Indexed'), ('failed', 'Failed')], default='processing')
is_active = fields.Boolean(string="Active", help="If the source is active, it will be used in the RAG context.")
error_details = fields.Text(string="Error Details", readonly=True)
attachment_id = fields.Many2one('ir.attachment', string="Attachment", index=True, ondelete='cascade')
mimetype = fields.Char(related='attachment_id.mimetype')
file_size = fields.Integer(related='attachment_id.file_size')
url = fields.Char(string="URL")
user_has_access = fields.Boolean(compute="_compute_user_has_access", readonly=True)
@api.model_create_multi
def create(self, vals_list):
sources = super().create(vals_list)
trigger_embeddings_cron = False
trigger_scrape_urls_cron = False
for source in sources:
if source.attachment_id:
source.attachment_id.write({
'res_model': 'ai.agent.source',
'res_id': source.id,
})
if source.type == 'binary' and source.status == 'processing':
trigger_embeddings_cron = True
elif source.type == 'url':
trigger_scrape_urls_cron = True
if trigger_embeddings_cron:
self.env.ref('ai.ir_cron_generate_embedding')._trigger()
if trigger_scrape_urls_cron:
self.env.ref('ai.ir_cron_process_sources')._trigger()
return sources
@api.model
def create_from_attachments(self, attachment_ids, agent_id):
"""
Create AI agent sources from existing attachments.
:param attachment_ids: list of attachment IDs
:type attachment_ids: list of int
:param agent_id: agent id
:type agent_id: int
:return: list of created AI agent sources
:rtype: list of ai.agent.source records
"""
attachments = self.env['ir.attachment'].browse(attachment_ids)
checksums = attachments.mapped('checksum')
agent = self.env['ai.agent'].browse(agent_id)
embedding_model = agent._get_embedding_model()
existing_checksums = set(
self.env['ai.embedding'].search([('checksum', 'in', checksums), ('embedding_model', '=', embedding_model)]).mapped('checksum')
)
# Filter attachments that already have embeddings using their checksum
matching_attachments = attachments.filtered(lambda a: a.checksum in existing_checksums)
vals_list = []
for attachment in attachments:
source = {
'name': attachment.name,
'agent_id': agent_id,
'attachment_id': attachment.id,
'type': 'binary',
}
if attachment in matching_attachments:
source['status'] = 'indexed'
source['is_active'] = True
vals_list.append(source)
return self.create(vals_list)
@api.model
def create_from_binary_files(self, files_datas, agent_id):
"""
Create AI agent sources from binary records.
:param records: list of records with name and datas
:type records: list of dicts
:param agent_id: agent id
:type agent_id: int
:return: list of created AI agent sources
:rtype: list of ai.agent.source records
"""
attachment_ids = self.env['ir.attachment'].create(files_datas).ids
return self.create_from_attachments(attachment_ids, agent_id)
@api.model
def create_from_urls(self, urls, agent_id):
"""
Create AI agent sources from URLs.
:param urls: list of urls
:type urls: list of str
:param agent_id: agent id
:type agent_id: int
:return: list of created AI agent sources
:rtype: list of ai.agent.source records
"""
if not urls:
return self.browse()
if not self.env.is_system():
raise AccessError(_('Only administrators can create sources from URLs.'))
request_session = requests.Session()
vals_list = []
for url in urls:
name = self._get_name_from_url(url, request_session)
url_source = {
'name': name,
'url': url,
'agent_id': agent_id,
'type': 'url',
}
vals_list.append(url_source)
return self.create(vals_list)
@api.model
def _get_name_from_url(self, url, session):
"""
Get the name of the source from the URL.
:param url: URL to get the name from
:type url: str
:param session: request session
:type session: requests.Session
:return: name of the source
:rtype: str
"""
preview = link_preview.get_link_preview_from_url(url, session)
if preview and preview.get('og_title'):
return preview['og_title']
return url
@api.ondelete(at_uninstall=False)
def _unlink_attachments(self):
"""Delete attachments when a source is deleted."""
for source in self:
if source.attachment_id:
source.attachment_id.unlink()
@api.depends_context('uid')
@api.depends('attachment_id')
def _compute_user_has_access(self):
"""
Compute user access by delegating to the underlying source.
Overriden in ai_documents and ai_knowledge.
"""
self.filtered(lambda s: s.type == 'binary').user_has_access = self.env.user._is_internal()
self.filtered(lambda s: s.type == 'url').user_has_access = True
def _get_source_embeddings_status(self, embedding_model):
"""
Get the current embedding status for a source with the given model.
:param embedding_model: embedding model
:type embedding_model: str
:return: dict with status info
:rtype: dict
"""
self.ensure_one()
# Get all embeddings for this source
checksum = self.attachment_id.checksum
embedding_chunks = self.env['ai.embedding'].search([
('checksum', '=', checksum),
('embedding_model', '=', embedding_model)
])
if not embedding_chunks:
return {'has_chunks': False}
failed_count = embedding_chunks.filtered('has_embedding_generation_failed')
missing_vectors = embedding_chunks.filtered(lambda e: not e.embedding_vector)
return {
'has_chunks': True,
'has_failed': bool(failed_count),
'has_missing': bool(missing_vectors),
}
def _update_source_status(self, embedding_model):
"""
Update source status based on current embedding state.
:param embedding_model: embedding model
:type embedding_model: str
:return: True if source status was updated, False otherwise
:rtype: bool
"""
self.ensure_one()
embedding_info = self._get_source_embeddings_status(embedding_model)
if not embedding_info['has_chunks']:
# No chunks exist, it needs to be processed first
return False
elif embedding_info.get('has_failed'):
self.write({
'status': 'failed',
'is_active': False,
'error_details': _("Embedding generation failed using the current LLM model selected."),
})
elif embedding_info.get('has_missing'):
self.write({
'status': 'processing',
'is_active': False,
'error_details': False,
})
else:
# Has chunks and no missing vectors, it's ready to be used
self.write({
'status': 'indexed',
'is_active': True,
'error_details': False,
})
return True
def _sync_new_agent_provider(self, embedding_model):
"""
Sync sources when embedding model changes.
:param embedding_model: embedding model
:type embedding_model: str
"""
sources_to_process = self.env[self._name]
for source in self:
can_update = source._update_source_status(embedding_model)
# If the source can't be updated and it has an attachment, it means it has no chunks,
# so we need to process it first
if not can_update and source.attachment_id:
source.write({
'status': 'processing',
'is_active': False,
'error_details': False,
})
sources_to_process |= source
if sources_to_process:
self.env.ref('ai.ir_cron_generate_embedding')._trigger()
def action_retry_failed_source(self):
"""
Retry failed sources by deleting the source chunks and re-triggering the appropriate cron job.
"""
self.ensure_one()
if self.status == 'failed':
source_chunks = self.env['ai.embedding'].search([('checksum', '=', self.attachment_id.checksum), ('embedding_model', '=', self.agent_id._get_embedding_model())])
if source_chunks:
source_chunks.unlink()
if self.url:
cron = 'ai.ir_cron_process_sources'
elif self.attachment_id:
cron = 'ai.ir_cron_generate_embedding'
else:
cron = False
if cron:
self.write({
'status': 'processing',
'is_active': False,
'error_details': False,
})
self.env.ref(cron)._trigger()
def action_open_sources_dialog(self):
"""
Open the add sources dialog.
"""
agent_id = self.env.context.get('agent_id')
return {
"type": "ir.actions.client",
"tag": "ai_open_sources_dialog",
"params": {
"agent_id": agent_id,
}
}
def action_access_source(self):
"""
Access the source content. Overriden in ai_documents and ai_knowledge.
"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': self.url or f'/web/content/{self.attachment_id.id}?download=true',
'target': 'new',
}
def action_reprocess_index(self):
"""
Reprocess the index of the source.
"""
self.ensure_one()
sources_to_reprocess = self.env['ai.agent.source'].search([
('attachment_id.checksum', '=', self.attachment_id.checksum)
])
sources_to_reprocess.write({
'status': 'processing',
'is_active': False,
})
sources_to_reprocess._update_name()
self.env.ref('ai.ir_cron_process_sources')._trigger()
def _update_name(self):
"""
Update the name of the source if needed.
To be Overriden.
"""
if not self:
return
source = self[0]
if source.type == 'url':
request_session = requests.Session()
current_name = self._get_name_from_url(source.url, request_session)
if source.name != current_name:
self.name = current_name
def _get_internal_domains(self):
"""
To be overridden in ai_website
:return: list of internal domains
:rtype: list of str
"""
return []
def _cron_process_sources(self):
"""
Scrape and process all sources that require content retrieval.
This method fetches the latest content for each source, then creates
or updates the corresponding attachments.
"""
sources_to_process = self._get_sources_to_process()
if not sources_to_process:
return
# Group sources by URL to process them
sources_by_url = defaultdict(lambda: self.env['ai.agent.source'])
for source in sources_to_process:
sources_by_url[source.url] |= source
trigger_embeddings_cron = False
if sources_by_url:
trigger_embeddings_cron = self._process_sources_content(sources_by_url)
if trigger_embeddings_cron:
self.env.ref('ai.ir_cron_generate_embedding')._trigger()
def _get_sources_to_process(self):
"""
Get the sources to process.
Default for URL sources.
To be Overriden.
:return: sources to process
:rtype: ai.agent.source recordset
"""
target_urls = self.env['ai.agent.source'].search([
('type', '=', 'url'),
('status', '=', 'processing'),
]).mapped('url')
sources_to_process = self.env['ai.agent.source'].search([
('url', 'in', target_urls),
])
return sources_to_process
def _fetch_content(self, source):
"""
Fetch the content of a url source.
Default for URL sources.
To be Overriden.
:param source: source to fetch the content from
:type source: ai.agent.source record
:return: dictionary with 'content', 'title', and 'error' keys, or None
:rtype: dict or None
"""
if source.type == 'url' and source.url:
internal_domains = self._get_internal_domains()
"""
Add the agent creator's language explicitly to self environment
because it is missing inside cron jobs and thus translations
are not possible
"""
lang = source.agent_id.create_uid.lang or 'en_US'
env = self.with_context(lang=lang).env
extractor = HTMLExtractor(env, internal_domains=internal_domains)
result = extractor.scrap(source.url)
if not result or not result['content']:
return {"content": None, "error": result.get('error', env._("Failed to fetch the content of the source."))}
return result
return {'error': env._("Failed to fetch the content of the source.")}
def _get_sources_indexing_state(self, sources, updated_checksum):
"""
Determine the sources' embedding and indexing state.
:param sources: recordset of sources to determine the state
:type sources: ai.agent.source recordset
:param updated_checksum: checksum of the updated content
:type updated_checksum: str
:return: tuple of (indexed_sources, sources_to_update_status)
:rtype: tuple
"""
indexed_embedding_models = self.env['ai.embedding']._get_indexed_embedding_models_by_checksum(updated_checksum)
indexed_sources = sources.filtered(lambda source: source.agent_id._get_embedding_model() in indexed_embedding_models)
non_indexed_sources = sources - indexed_sources
sources_to_update_status = non_indexed_sources.filtered(lambda source: source.status != 'processing')
trigger_embeddings_cron = bool(non_indexed_sources)
return indexed_sources, sources_to_update_status, trigger_embeddings_cron
def _update_sources_status(self, indexed_sources, processing_sources, failed_by_error=None):
"""
Update sources' status based on embedding availability.
:param indexed_sources: recordset of indexed sources
:type indexed_sources: ai.agent.source recordset
:param processing_sources: recordset of processing sources
:type processing_sources: ai.agent.source recordset
:param failed_by_error: dictionary mapping error messages to sources
:type failed_by_error: dict
"""
if indexed_sources:
indexed_sources.write({
'status': 'indexed',
'is_active': True,
})
if processing_sources:
processing_sources.write({
'status': 'processing',
'is_active': False,
})
if failed_by_error:
for error_msg, sources in failed_by_error.items():
sources.write({
'status': 'failed',
'is_active': False,
'error_details': error_msg,
})
def _create_sources_attachments(self, sources, attachments_vals):
"""
Create attachments for sources.
:param sources: recordset of sources to create attachments for
:type sources: ai.agent.source recordset
:param attachments_vals: list of dictionaries with attachment values
:type attachments_vals: list of dicts
"""
new_attachments = self.env['ir.attachment'].create(attachments_vals)
for source, new_attachment in zip(sources, new_attachments):
source.attachment_id = new_attachment.id
def _process_sources_content(self, sources_by_url):
"""
Process source content and create/update their attachments.
:param sources_by_url: dictionary mapping URLs to sources recordsets
:type sources_by_url: dict
:return: True if embeddings need to be generated, False otherwise
:rtype: bool
"""
trigger_embeddings_cron = False
indexed_sources = self.env['ai.agent.source']
sources_to_update_status = self.env['ai.agent.source']
attachments_embeddings_to_unlink = self.env['ir.attachment']
failed_by_error = defaultdict(lambda: self.env['ai.agent.source'])
for url, url_sources in sources_by_url.items():
# Fetch content from a single representative source
result = self._fetch_content(url_sources[0])
if not result or not result['content']:
failed_by_error[result['error']] |= url_sources
continue
updated_content = result['content'].encode()
updated_content_checksum = self.env['ir.attachment']._compute_checksum(updated_content)
# Check for the new sources to attach
sources_to_attach = url_sources.filtered(lambda s: not s.attachment_id)
attachment_vals_list = []
for source in sources_to_attach:
attachment_vals_list.append({
'name': f"{source.name}-({url})",
'res_model': 'ai.agent.source',
'res_id': source.id,
'raw': updated_content,
'mimetype': 'text/html',
'url': url,
})
if attachment_vals_list:
self._create_sources_attachments(sources_to_attach, attachment_vals_list)
# Check for any sources with outdated attachments content or not indexed
attachments_to_update = url_sources.filtered(lambda source: source.attachment_id.checksum != updated_content_checksum).mapped('attachment_id')
attachments_embeddings_to_unlink |= attachments_to_update
url_indexed_sources, url_sources_to_update_status, url_trigger_embeddings_cron = self._get_sources_indexing_state(url_sources, updated_content_checksum)
indexed_sources |= url_indexed_sources
sources_to_update_status |= url_sources_to_update_status
trigger_embeddings_cron |= url_trigger_embeddings_cron
if attachments_to_update:
attachments_to_update.write({
'raw': updated_content,
})
if attachments_embeddings_to_unlink:
self.env['ai.embedding'].search([
('attachment_id', 'in', attachments_embeddings_to_unlink.ids)
]).unlink()
self._update_sources_status(indexed_sources, sources_to_update_status, failed_by_error)
return trigger_embeddings_cron