From 0d448d950c5acaf993554b6d5464b56c0767b306 Mon Sep 17 00:00:00 2001 From: "Lou (loha)" Date: Tue, 9 May 2023 14:59:47 +0200 Subject: [PATCH] [IMP] website: generate text with a LLM for configurator pages When building a website with the website configurator, you get pages with a nice layout. However, the text inside is not adapted to your company, industry or even, most of the time, language. Using a LLM, we could easily get text much more relevant to customer needs. The scope of this work is static pages generated with the website configurator (homepage, about us, pricing, ...). Later, in other tasks, we also want users to be able to generate text inside the website builder. This commit implement the bare minimum to generate and replace a website content by AI generated sentences (based on the information provided by the user). Related IAP PR at https://github.com/odoo/iap-apps/pull/656 task-3248852 closes odoo/odoo#121021 Signed-off-by: Quentin Smetz (qsm) Co-authored-by: qsm-odoo --- addons/website/models/website.py | 82 ++++++++++++++++--- .../configurator/configurator.js | 1 + addons/website/tests/test_configurator.py | 2 + 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/addons/website/models/website.py b/addons/website/models/website.py index f221d6a128a..144af202efd 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -14,7 +14,6 @@ from psycopg2 import sql from werkzeug import urls from werkzeug.datastructures import OrderedMultiDict from werkzeug.exceptions import NotFound -from markupsafe import Markup from odoo import api, fields, models, tools, http, release, registry from odoo.addons.http_routing.models.ir_http import RequestUID, slugify, url_for @@ -26,7 +25,7 @@ from odoo.exceptions import AccessError, MissingError, UserError, ValidationErro from odoo.http import request from odoo.modules.module import get_resource_path, get_manifest from odoo.osv.expression import AND, OR, FALSE_DOMAIN, get_unaccent_wrapper -from odoo.tools.translate import _ +from odoo.tools.translate import _, xml_translate from odoo.tools import escape_psql, pycompat logger = logging.getLogger(__name__) @@ -42,7 +41,8 @@ DEFAULT_CDN_FILTERS = [ "^/website/image/", ] -DEFAULT_ENDPOINT = 'https://website.api.odoo.com' +DEFAULT_WEBSITE_ENDPOINT = 'https://website.api.odoo.com' +DEFAULT_OLG_ENDPOINT = 'https://olg.api.odoo.com' class Website(models.Model): @@ -333,12 +333,20 @@ class Website(models.Model): # ---------------------------------------------------------- # Configurator # ---------------------------------------------------------- - def _website_api_rpc(self, route, params): + + def _api_rpc(self, route, params, endpoint_param_name, default_endpoint, **kwargs): params['version'] = release.version IrConfigParameter = self.env['ir.config_parameter'].sudo() - website_api_endpoint = IrConfigParameter.get_param('website.website_api_endpoint', DEFAULT_ENDPOINT) - endpoint = website_api_endpoint + route - return iap_tools.iap_jsonrpc(endpoint, params=params) + api_endpoint = IrConfigParameter.get_param(endpoint_param_name, default_endpoint) + return iap_tools.iap_jsonrpc(api_endpoint + route, params=params, **kwargs) + + def _website_api_rpc(self, route, params): + # For industries, theme suggestions, ... + return self._api_rpc(route, params, 'website.website_api_endpoint', DEFAULT_WEBSITE_ENDPOINT) + + def _OLG_api_rpc(self, route, params): + # For text content generation + return self._api_rpc(route, params, 'website.olg_api_endpoint', DEFAULT_OLG_ENDPOINT, timeout=300) def get_cta_data(self, website_purpose, website_type): return {'cta_btn_text': False, 'cta_btn_href': '/contactus'} @@ -577,9 +585,55 @@ class Website(models.Model): {'theme': theme_name} ) - # Configure the pages - requested_pages = list(pages_views.keys()) + ['homepage'] + # Generate text for the pages + requested_pages = set(pages_views.keys()).union({'homepage'}) snippet_lists = website.get_theme_snippet_lists(theme_name) + industry = kwargs['industry_name'] + + IrQweb = self.env['ir.qweb'].with_context(website_id=website.id, lang=website.default_lang_id.code) + snippets_cache = {} + + def _compute_placeholder(term): + return xml_translate.get_text_content(term).strip() + + def _render_snippet(key): + # Using this avoids rendering the same snippet multiple times + data = snippets_cache.get(key) + if data: + return data + + render = IrQweb._render(key, cta_data) + + terms = [] + xml_translate(terms.append, render) + placeholders = [_compute_placeholder(term) for term in terms] + + data = (render, placeholders) + snippets_cache[key] = data + return data + + generated_content = {} + for page_code in requested_pages - {'privacy_policy'}: + snippet_list = snippet_lists.get(page_code, []) + for snippet in snippet_list: + render, placeholders = _render_snippet(f'website.{snippet}') + for placeholder in placeholders: + generated_content[placeholder] = '' + try: + response = self._OLG_api_rpc('/api/olg/1/generate_placeholder', { + 'placeholders': list(generated_content.keys()), + 'lang': website.default_lang_id.name, + 'industry': industry, + }) + name_replace_parser = re.compile(r"XXXX", re.MULTILINE) + for key in generated_content: + if response.get(key): + generated_content[key] = (name_replace_parser.sub(website.name, response[key], 0)) + except AccessError: + # If IAP is broken continue normally (without generating text) + pass + + # Configure the pages for page_code in requested_pages: snippet_list = snippet_lists.get(page_code, []) if page_code == 'homepage': @@ -590,8 +644,14 @@ class Website(models.Model): nb_snippets = len(snippet_list) for i, snippet in enumerate(snippet_list, start=1): try: - IrQweb = self.env['ir.qweb'].with_context(website_id=website.id, lang=website.default_lang_id.code) - render = IrQweb._render('website.' + snippet, cta_data) + render, placeholders = _render_snippet(f'website.{snippet}') + + # Fill rendered block with AI text + render = xml_translate( + lambda x: generated_content.get(_compute_placeholder(x), x), + render + ) + el = html.fromstring(render) # Add the data-snippet attribute to identify the snippet diff --git a/addons/website/static/src/client_actions/configurator/configurator.js b/addons/website/static/src/client_actions/configurator/configurator.js index 6b35c81a3e1..0955a57746d 100644 --- a/addons/website/static/src/client_actions/configurator/configurator.js +++ b/addons/website/static/src/client_actions/configurator/configurator.js @@ -362,6 +362,7 @@ class ApplyConfiguratorScreen extends Component { const data = { 'selected_features': selectedFeatures, 'industry_id': this.state.selectedIndustry.id, + 'industry_name': this.state.selectedIndustry.label, 'selected_palette': selectedPalette, 'theme_name': themeName, 'website_purpose': WEBSITE_PURPOSES[ diff --git a/addons/website/tests/test_configurator.py b/addons/website/tests/test_configurator.py index 2922e7ef996..036c85cebc7 100644 --- a/addons/website/tests/test_configurator.py +++ b/addons/website/tests/test_configurator.py @@ -39,6 +39,8 @@ class TestConfiguratorCommon(odoo.tests.HttpCase): return [] elif '/api/website/2/configurator/custom_resources/' in endpoint: return {'images': {}} + elif '/api/olg/1/generate_placeholder' in endpoint: + return {"a non existing placeholder": "😠", 'Catchy Headline': 'Welcome to XXXX - Your Super test'} iap_patch = patch('odoo.addons.iap.tools.iap_tools.iap_jsonrpc', iap_jsonrpc_mocked_configurator) self.startPatcher(iap_patch)