234 lines
9.5 KiB
Python
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 {}
|