Files
enterprise-addons/crm_enterprise/tools/business_card_scanner.py
T

234 lines
9.5 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import requests
from odoo import _
from odoo.addons.iap.tools import iap_tools
from odoo.exceptions import UserError
from odoo.fields import Domain
from odoo.tools.urls import urljoin as url_join
_logger = logging.getLogger(__name__)
PROMPT = (
"Extract details from the provided business card image as a JSON object. "
"I will use the response you give me to extract data in an automated process so respect instructions or else it will fail. "
"I will specify a list of the information I need, do NOT add anything else."
"Consider this request with a very low temperature, DO NOT GUESS or try to be creative, only use the text you can read on the image, do not make things up! "
"I don't want to see anything like 'Not specified' or 'Unknown' in the resulting JSON, don't specify the fields if you don't know. "
"If you do not have AT LEAST a 'contact_name' OR a 'company_name', return an empty JSON. "
"Reply with ONLY the JSON object on a SINGLE LINE, without code editor or styling. "
"Here are the information I need, the field name in your resulting JSON is specified each time between quotes: "
"- The name of the company, as 'partner_name' "
"- The name of the contact (the person), as 'contact_name' "
"- The main phone number (only ONE), as 'phone' "
"- The main email address (only ONE), as 'email_from' "
"- The company or contact website (only ONE), as 'website' "
"- The Job Position, as 'function' "
"- The city as 'city' "
"- The zip code as 'zip' "
"- The state code as 'state_code', it needs to be a code and not the full name (example: Q1 for London), if you don't know the code, don't specify this field "
"- The country code as 'country_code', in a 2 characters ISO code (NOT 3 characters, 2), if you don't know the ISO code, don't specify this field "
"- The first part of the address that is not in the above fields, usually the street and number, as 'street' "
"- The second part of the address, only if it is obvious there are 2 parts, as 'street2' "
)
class BusinessCardScanner:
def __init__(self, env):
self.env = env
def business_cards_to_leads(self, attachments):
""" Create opportunities from the details extracted from images of business cards. """
openai_api_key = self.env["ir.config_parameter"].sudo().get_param("crm_enterprise.business_card_reader.openai_key")
valid_attachments = []
lead_values_list = []
session = False
if openai_api_key:
session = requests.Session() # re-usable session to batch requests
for attachment in attachments:
image_url = f"{attachment.get_base_url()}/web/image/{attachment.id}?access_token={attachment.access_token}"
try:
if openai_api_key:
extracted_data = self._ocr_from_openai(image_url, openai_api_key, session)
else:
extracted_data = self._ocr_from_iap(image_url)
except requests.exceptions.RequestException:
raise UserError(_("Network error while processing the business cards."))
if not extracted_data:
continue
# Normalize to list of dicts
if isinstance(extracted_data, dict):
extracted_data = [extracted_data]
fields_allowlist = [
'partner_name',
'contact_name',
'email_from',
'phone',
'website',
'function',
'street',
'street2',
'zip',
]
for data in extracted_data:
lead_values = {
field: data[field].strip()
for field in fields_allowlist
if data.get(field)
}
lead_values['type'] = 'opportunity'
contact_name = lead_values.get('contact_name')
company_name = lead_values.get('partner_name')
if contact_name or company_name:
lead_values['name'] = _("%(contact_name)s's opportunity", contact_name=contact_name or company_name)
else:
continue # safety net as we told AI to return an empty result in this case
# extract country
country = False
if data.get('country_code') and len(data['country_code']) == 2:
country = self.env['res.country'].search([('code', '=', data['country_code'])], limit=1)
if country:
lead_values['country_id'] = country.id
# extract state
if data.get('state_code'):
domain = Domain('code', '=', data['state_code'])
if country:
domain &= Domain('country_id', '=', country.id)
state = self.env['res.country.state'].search(domain, limit=1)
if state:
lead_values['state_id'] = state.id
# extract city
if data.get('city'):
lead_values['city'] = data['city']
# format phone
if lead_values.get('phone'):
lead_values['phone'] = self.env['crm.lead']._phone_format(
number=lead_values['phone'],
country=country,
)
lead_values_list.append(lead_values)
valid_attachments.append(attachment)
if len(attachments) == 1 and not lead_values_list:
# special case when a single attachment fails: do not create records
# caller is responsible for followup behavior and feedback in that case
return self.env['crm.lead']
leads = self.env['crm.lead'].create(lead_values_list)
for lead, attachment in zip(leads, valid_attachments):
attachment.write({
'res_model': 'crm.lead',
'res_id': lead.id
})
lead._message_log(
body=_('Lead generated from this image.'),
attachment_ids=[attachment.id],
)
# create "empty leads" for failing images to give better user feedback
invalid_attachments = attachments.filtered(lambda attachment: attachment not in valid_attachments)
empty_leads = self.env['crm.lead'].create([{
'name': _('New Lead Scan')
} for i in range(len(invalid_attachments))])
for lead, attachment in zip(empty_leads, invalid_attachments):
attachment.write({
'res_model': 'crm.lead',
'res_id': lead.id
})
lead._message_log(
body=_('Could not extract lead information from this image.'),
attachment_ids=[attachment.id],
)
return leads + empty_leads
def _ocr_from_openai(self, image_url, api_key, session):
response = session.post(
"https://api.openai.com/v1/chat/completions",
json={
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": PROMPT},
{
"type": "image_url",
"image_url": {
"url": image_url
}
}
]}
],
},
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
},
timeout=20,
)
if response.ok:
response_content = response.json()
result = response_content['choices'][0]['message']['content']
try:
return json.loads(result)
except json.JSONDecodeError:
_logger.warning('openAI returned non-parsable response: %s', response.text)
return {}
else:
_logger.warning('openAI failure: %s', response.text)
raise UserError(
_("Oops, the AI service is not working 🤖. Please contact your administrator to verify settings.")
)
def _ocr_from_iap(self, image_url):
IrConfigParameter = self.env["ir.config_parameter"].sudo()
olg_api_endpoint = IrConfigParameter.get_param(
"html_editor.olg_api_endpoint", "https://olg.api.odoo.com"
)
database_id = IrConfigParameter.get_param("database.uuid")
response = iap_tools.iap_jsonrpc(
url_join(olg_api_endpoint, "/api/olg/1/chat"),
params={
"prompt": PROMPT,
"conversation_history": [
{"role": "user", "content": [
{"type": "image_url", "image_url": {
"url": image_url}
}
]}
],
"database_id": database_id,
},
timeout=20,
)
if response['status'] != 'success':
return {} # nothing to log in this case, IAP provides 0 info
result = response['content']
try:
return json.loads(result)
except json.JSONDecodeError:
_logger.warning('IAP returned non-parsable response: %s', result)
return {}