diff --git a/config/acquia_dev/config_ignore.settings.yml b/config/acquia_dev/config_ignore.settings.yml index f6a0edefda..3a0d00b85b 100644 --- a/config/acquia_dev/config_ignore.settings.yml +++ b/config/acquia_dev/config_ignore.settings.yml @@ -9,6 +9,7 @@ ignored_config_entities: - bos_google_cloud.prompts - bos_mnl.settings - bos_swiftype.settings + - bos_search.settings:presets - 'core.entity_view_display.node.metrolist_development.*' - geolocation_google_maps.settings - 'paragraphs.paragraphs_type.*:dependencies.content|icon_uuid' diff --git a/config/acquia_prod/config_ignore.settings.yml b/config/acquia_prod/config_ignore.settings.yml index eec14eda6b..e2e97556a4 100644 --- a/config/acquia_prod/config_ignore.settings.yml +++ b/config/acquia_prod/config_ignore.settings.yml @@ -9,6 +9,7 @@ ignored_config_entities: - bos_google_cloud.prompts - bos_mnl.settings - bos_swiftype.settings + - bos_search.settings:presets - 'core.entity_view_display.node.metrolist_development.*' - geolocation_google_maps.settings - 'paragraphs.paragraphs_type.*:dependencies.content|icon_uuid' diff --git a/config/acquia_stage/config_ignore.settings.yml b/config/acquia_stage/config_ignore.settings.yml index 3b6c28dd4d..c16052fbb0 100644 --- a/config/acquia_stage/config_ignore.settings.yml +++ b/config/acquia_stage/config_ignore.settings.yml @@ -9,6 +9,7 @@ ignored_config_entities: - bos_google_cloud.prompts - bos_mnl.settings - bos_swiftype.settings + - bos_search.settings:presets - 'core.entity_view_display.node.metrolist_development.*' - geolocation_google_maps.settings - 'paragraphs.paragraphs_type.*:dependencies.content|icon_uuid' diff --git a/config/default/block.block.bos_theme_aienabledsearchbutton.yml b/config/default/block.block.bos_theme_aienabledsearchbutton.yml new file mode 100644 index 0000000000..9a5fa4db05 --- /dev/null +++ b/config/default/block.block.bos_theme_aienabledsearchbutton.yml @@ -0,0 +1,31 @@ +uuid: 7cb0ed9c-1a5d-4a8e-b644-4ba29e006c28 +langcode: en +status: true +dependencies: + module: + - bos_search + - system + theme: + - bos_theme +id: bos_theme_aienabledsearchbutton +theme: bos_theme +region: content +weight: -2 +provider: null +plugin: Ai-enabled-search-button +settings: + id: Ai-enabled-search-button + label: 'Beta Search' + label_display: '0' + provider: bos_search + search_button_title: 'Try AI Search' + search_button_css: '' + aisearch_config_preset: vertex_search + aisearch_config_display: '1' + aisearch_config_searchpage: /search-beta-page + search_block_text: 'Through our AI search, you can quickly find semantic answers to your questions related to Office of Economic Opportunity and Inclusion services.' +visibility: + request_path: + id: request_path + negate: false + pages: /ai-search diff --git a/config/default/block.block.bos_theme_aienabledsearchbutton_2.yml b/config/default/block.block.bos_theme_aienabledsearchbutton_2.yml new file mode 100644 index 0000000000..a8ae30f388 --- /dev/null +++ b/config/default/block.block.bos_theme_aienabledsearchbutton_2.yml @@ -0,0 +1,31 @@ +uuid: 9f83fc29-e8dc-40c9-bba5-799b507f8d90 +langcode: en +status: false +dependencies: + module: + - bos_search + - system + theme: + - bos_theme +id: bos_theme_aienabledsearchbutton_2 +theme: bos_theme +region: content +weight: -1 +provider: null +plugin: Ai-enabled-search-button +settings: + id: Ai-enabled-search-button + label: 'Modal Search Button' + label_display: visible + provider: bos_search + search_button_title: 'Search Modal' + search_button_css: '' + aisearch_config_preset: vertex_conversation + aisearch_config_display: '0' + aisearch_config_searchpage: '' + search_block_text: '' +visibility: + request_path: + id: request_path + negate: false + pages: /ai-search diff --git a/config/default/block.block.bos_theme_aienabledsearchform.yml b/config/default/block.block.bos_theme_aienabledsearchform.yml new file mode 100644 index 0000000000..1581ec9205 --- /dev/null +++ b/config/default/block.block.bos_theme_aienabledsearchform.yml @@ -0,0 +1,27 @@ +uuid: 838600a1-c35e-4157-b1a9-30b2fa5ad3ca +langcode: en +status: true +dependencies: + module: + - bos_search + - system + theme: + - bos_theme +id: bos_theme_aienabledsearchform +theme: bos_theme +region: content +weight: 0 +provider: null +plugin: Ai-enabled-search-form +settings: + id: Ai-enabled-search-form + label: 'AI Enabled Search Form' + label_display: '0' + provider: bos_search + search_form_title: null + aisearch_config_preset: vertex_search +visibility: + request_path: + id: request_path + negate: false + pages: /search-beta-page diff --git a/config/default/block.block.footermenu.yml b/config/default/block.block.footermenu.yml index 05b396debf..ac0e2cb76e 100644 --- a/config/default/block.block.footermenu.yml +++ b/config/default/block.block.footermenu.yml @@ -27,6 +27,8 @@ settings: depth: 0 expand_all_items: false parent: 'menu-footer-menu:' + render_parent: false suggestion: menu_footer_menu + hide_on_nonactive: false expand: 0 visibility: { } diff --git a/config/default/block.block.mainmenu.yml b/config/default/block.block.mainmenu.yml index 673ea12bc0..9385ee6a42 100644 --- a/config/default/block.block.mainmenu.yml +++ b/config/default/block.block.mainmenu.yml @@ -27,6 +27,8 @@ settings: depth: 0 expand_all_items: false parent: 'main:' + render_parent: false suggestion: menu_main_menu + hide_on_nonactive: false expand: 0 visibility: { } diff --git a/config/default/bos_google_cloud.prompts.yml b/config/default/bos_google_cloud.prompts.yml index 8c2eb82580..e2355f4878 100644 --- a/config/default/bos_google_cloud.prompts.yml +++ b/config/default/bos_google_cloud.prompts.yml @@ -1,5 +1,5 @@ base: '[]' summarizer: '{"spam":"UHJvdmlkZSBhIHByb2JhYmlsaXR5IHRoYXQgdGhpcyBlbWFpbCB0ZXh0IGlzIHNwYW0u","rollcall":"UHJvdmlkZSBhIHNob3J0IHRleHQgdGl0bGUgZm9yIHRoZSBmb2xsb3dpbmcgdGV4dCwgd2hpY2ggaXMgYSB2b3RpbmcgcmVjb3JkIG9mIGEgQ2l0eSBDb3VuY2lsIG1lZXRpbmcuIE9ubHkgdGhlIHRpdGxlIGlzIHJlcXVpcmVkLiBEbyBub3QgaW5jbHVkZSBtYXJrZG93biBmb3JtYXR0aW5nLg=="}' rewriter: '[]' -search: '[]' +search: '{"aiSearch":"R2l2ZW4gdGhlIGNvbnZlcnNhdGlvbiBiZXR3ZWVuIGEgdXNlciBhbmQgYSBoZWxwZnVsIGFzc2lzdGFudCBhbmQgc29tZSBzZWFyY2ggcmVzdWx0cywgY3JlYXRlIGEgZmluYWwgYW5zd2VyIGZvciB0aGUgYXNzaXN0YW50LiBUaGUgYW5zd2VyIHNob3VsZCB1c2UgYWxsIHJlbGV2YW50IGluZm9ybWF0aW9uIGZyb20gdGhlIHNlYXJjaCByZXN1bHRzLCBub3QgaW50cm9kdWNlIGFueSBhZGRpdGlvbmFsIGluZm9ybWF0aW9uLCBhbmQgdXNlIGV4YWN0bHkgdGhlIHNhbWUgd29yZHMgYXMgdGhlIHNlYXJjaCByZXN1bHRzIHdoZW4gcG9zc2libGUuIFRoZSBhc3Npc3RhbnQncyBhbnN3ZXIgc2hvdWxkIGJlIG5vIG1vcmUgdGhhbiAyMCBzZW50ZW5jZXMuIFRoZSB1c2VyIGlzIGEgbWVtYmVyIG9mIHRoZSBnZW5lcmFsIHB1YmxpYyB3aG8gZG9lc24ndCBoYXZlIGluLWRlcHRoIGtub3dsZWRnZSBvZiB0aGUgc3ViamVjdCBtYXR0ZXIuIFRoZSBhc3Npc3RhbnQgc2hvdWxkIGF2b2lkIHVzaW5nIHNwZWNpYWxpemVkIGtub3dsZWRnZSwgYW5kIGluc3RlYWQgYW5zd2VyIGluIGEgbm9uLXRlY2huaWNhbCBtYW5uZXIgdGhhdCBhbnlvbmUgY2FuIHVuZGVyc3RhbmQu"}' translation: '[]' diff --git a/config/default/bos_google_cloud.settings.yml b/config/default/bos_google_cloud.settings.yml index 923e4bc14e..94a5a6c9c3 100644 --- a/config/default/bos_google_cloud.settings.yml +++ b/config/default/bos_google_cloud.settings.yml @@ -21,17 +21,18 @@ search: endpoint: 'https://discoveryengine.googleapis.com' service_account: service_account_1 conversation: - project_id: '612042612588' - datastore_id: drupalwebsite_1702919119768 + project_id: '738313172788' + datastore_id: oeoi-pilot-datastore_1726265795910 location_id: global endpoint: 'https://discoveryengine.googleapis.com' service_account: service_account_1 - allow_conversation: 0 + allow_conversation: 1 + model: stable rewriter: project_id: vertex-ai-poc-406419 - model_id: gemini-pro - location_id: us-east4 - endpoint: 'https://us-east4-aiplatform.googleapis.com' + model_id: gemini-1.5-pro-preview-0409 + location_id: us-central1 + endpoint: 'https://us-central1-aiplatform.googleapis.com' service_account: service_account_1 cache: '+1 day' translate: diff --git a/config/default/bos_search.settings.yml b/config/default/bos_search.settings.yml new file mode 100644 index 0000000000..f5f4e61a08 --- /dev/null +++ b/config/default/bos_search.settings.yml @@ -0,0 +1,77 @@ +presets: + vertex_conversation: + name: 'Vertex Conversation' + aimodel: 'Vertex Conversation' + model_tuning: + prompt: default + safe_search: '1' + semantic_chunks: null + searchform: + theme: concierge + disclaimer: + enabled: '1' + show_once: '1' + text: 'We are trying something new! This is an experimental search that displays business-related results and information leveraging generative artificial intelligence (AI). Since this is a test site, there is a chance that some answers will be incomplete or inaccurate. Please double check answers using the provided links.' + modal_titlebartitle: '' + welcome: + body_title: 'What are you looking for?' + body_text: 'Our AI-generated search provides answers to your business and Economic Opportunity and Inclusion questions leveraging Google’s Vertex AI. As this is an experiment, some answers may be incorrect. You will be able to provide direct feedback below each response. If you have any questions about our work, please email ai@boston.gov.' + cards: + enabled: '1' + card_1: 'How do I get a food truck permit?' + card_2: 'How do I become a vendor with the City of Boston?' + card_3: 'How can I learn about funding opportunities?' + searchbar: + search_text: 'How can we help you?' + search_note: "Responses may occasionally produce inaccurate or incomplete content. Validate answers on\_Boston.gov" + allow_reset: null + audio_search_input: null + results: + waiting_text: 'Scanning boston.gov for information ...' + result_count: '5' + summary: '1' + no_result_text: '' + citations: '1' + searchresults: '1' + feedback: '1' + pid: vertex_conversation + metadata: null + pid: vertex_conversation + vertex_search: + name: 'Vertex Search' + aimodel: 'Vertex Conversation' + model_tuning: + prompt: aiSearch + safe_search: '1' + semantic_chunks: null + searchform: + theme: concierge + disclaimer: + enabled: '1' + text: 'We are trying something new! This is an experimental search that displays business-related results and information leveraging generative artificial intelligence (AI). Since this is a test site, there is a chance that some answers will be incomplete or inaccurate. Please double check answers using the provided links.' + show_once: null + modal_titlebartitle: '' + welcome: + body_title: 'What are you looking for?' + body_text: 'Our AI-generated search provides answers to your business and Economic Opportunity and Inclusion questions leveraging Google’s Vertex AI. As this is an experiment, some answers may be incorrect. You will be able to provide direct feedback below each response. If you have any questions about our work, please email ai@boston.gov.' + cards: + enabled: '1' + card_1: 'How do I get a food truck permit?' + card_2: 'How do I become a vendor with the City of Boston?' + card_3: 'How can I learn about funding opportunities?' + searchbar: + allow_reset: '1' + search_text: 'How can we help you?' + search_note: "Responses may occasionally produce inaccurate or incomplete content. Validate answers on\_Boston.gov" + audio_search_input: null + results: + waiting_text: 'Scanning boston.gov for information ...' + result_count: '5' + summary: '1' + no_result_text: 'No results were found. No results were found. No results were found. No results were found. No results were found. No results were found. No results were found. No results were found. No results were found. No results were found. No results were found. No results were found. ' + citations: '1' + searchresults: '1' + feedback: '1' + pid: vertex_search + metadata: null + pid: vertex_search diff --git a/config/default/core.extension.yml b/config/default/core.extension.yml index e006366536..21d98d3135 100644 --- a/config/default/core.extension.yml +++ b/config/default/core.extension.yml @@ -15,7 +15,6 @@ module: big_pipe: 0 block: 0 block_content: 0 - bos_charts: 0 breakpoint: 0 captcha: 0 chosen_lib: 0 @@ -245,11 +244,13 @@ module: node_transaction: 2 bos_311: 3 bos_assessing: 3 + bos_aws_services: 3 bos_bibblio: 3 bos_bid: 3 bos_branded_links: 3 bos_cabinet: 3 bos_card: 3 + bos_charts: 3 bos_city_score: 3 bos_commissions: 3 bos_components: 3 @@ -266,6 +267,7 @@ module: bos_fyi: 3 bos_geocoder: 3 bos_google_cloud: 3 + bos_gc_aisearch_plugin: 3 bos_grid: 3 bos_hero_image: 3 bos_iframe: 3 @@ -285,6 +287,7 @@ module: bos_photo: 3 bos_quote: 3 bos_seamless_doc: 3 + bos_search: 3 bos_shortcodes: 3 bos_sidebar: 3 bos_sql: 3 diff --git a/config/default/user.role.anonymous.yml b/config/default/user.role.anonymous.yml index d610136f45..f8c9d80207 100644 --- a/config/default/user.role.anonymous.yml +++ b/config/default/user.role.anonymous.yml @@ -3,6 +3,7 @@ langcode: en status: true dependencies: module: + - bos_search - file_entity - lightning_core - media @@ -20,6 +21,7 @@ is_admin: false permissions: - 'access content' - 'download any document files' + - 'view ai-enabled search permission' - 'view files' - 'view media' - 'view paragraph content 3_column_w_image' diff --git a/config/default/user.role.authenticated.yml b/config/default/user.role.authenticated.yml index b301802e49..0c37d39b30 100644 --- a/config/default/user.role.authenticated.yml +++ b/config/default/user.role.authenticated.yml @@ -24,6 +24,7 @@ permissions: - 'access workbench' - 'bypass honeypot protection' - 'download any document files' + - 'view ai-enabled search permission' - 'view files' - 'view media' - 'view own unpublished media' diff --git a/config/default/user.role.site_administrator.yml b/config/default/user.role.site_administrator.yml index 4848e65a69..329e6df92b 100644 --- a/config/default/user.role.site_administrator.yml +++ b/config/default/user.role.site_administrator.yml @@ -127,6 +127,7 @@ permissions: - 'access toolbar' - 'access user profiles' - 'administer account settings' + - 'administer ai-enabled search permission' - 'administer boston' - 'administer content moderation notifications' - 'administer content types' @@ -298,3 +299,4 @@ permissions: - 'view latest version' - 'view private files' - 'view salesforce mapping' + - 'administer ai-enabled search permission' diff --git a/config/default/webform.webform.ai_search_feedback.yml b/config/default/webform.webform.ai_search_feedback.yml new file mode 100644 index 0000000000..bf02f04114 --- /dev/null +++ b/config/default/webform.webform.ai_search_feedback.yml @@ -0,0 +1,295 @@ +uuid: 81d75a42-403a-4f5f-bf57-af02f384feb1 +langcode: en +status: open +dependencies: { } +weight: 0 +open: null +close: null +uid: 1 +template: false +archive: false +id: ai_search_feedback +title: 'AI Search Feedback' +description: 'Feedback for the search AI form' +categories: { } +elements: |- + happy: + '#type': checkbox + '#title': Happy + '#access': false + thumbsup: + '#type': webform_section + '#title': thumbsup + '#title_display': invisible + '#description_display': invisible + '#title_tag': '' + '#states': + visible: + ':input[name="happy"]': + checked: true + '#states_clear': false + what_went_well: + '#type': checkboxes + '#title': 'What went well?' + '#options': + correct: 'Correct Information -- The search returned information that is factually correct and useful to me.' + coherent: 'Easy to understand -- The search returned information that I found easy to understand and use.' + '#options_description_display': help + '#other__type': textarea + '#other__option_label': 'Tell us more' + '#other__placeholder': 'Share additional comments' + '#other__counter_type': character + '#other__counter_minimum': '1' + '#other__counter_maximum': '200' + '#other__counter_maximum_message': '200 characters allowed' + thumbsdown: + '#type': webform_section + '#title': thumbsdown + '#title_display': invisible + '#description_display': invisible + '#title_tag': '' + '#states': + visible: + ':input[name="happy"]': + unchecked: true + '#states_clear': false + what_went_wrong: + '#type': checkboxes + '#title': 'What went wrong?' + '#options': + hallucination: 'Incorrect Information -- The search returned information that is factually incorrect.' + outdated: 'Outdated Information -- The search returned information that was correct in the past, but is now out of date' + incoherent: 'Difficult to understand -- The search summary did not make sense, or was hard to understand.' + irrelevant: 'Not relevant to my query -- The search returned was not relevant to my query.
TIP, you can always re-frame your query and try again.' + '#options_description_display': help + '#other__type': textarea + '#other__option_label': 'Tell us more' + '#other__placeholder': 'Share additional comments' + '#other__counter_type': character + '#other__counter_minimum': '1' + '#other__counter_maximum': '200' + '#other__counter_maximum_message': '200 characters allowed' + tell_us_more: + '#type': textarea + '#title': 'Tell us more' + '#placeholder': 'Share additional comments' + '#autocomplete': '' + '#counter_type': character + '#counter_minimum': 10 + '#counter_maximum': 200 + '#counter_maximum_message': '200 characters allowed' + searchquestion: + '#type': hidden + '#title': SearchQuestion + '#attributes': + class: + - search-question + searchsummary: + '#type': hidden + '#title': SearchSummary + '#attributes': + class: + - search-summary + actions: + '#type': webform_actions + '#title': 'Submit button(s)' + '#update_hide': true +css: '' +javascript: '' +settings: + ajax: true + ajax_scroll_top: '' + ajax_progress_type: '' + ajax_effect: slide + ajax_speed: null + page: true + page_submit_path: '' + page_confirm_path: '' + page_theme_name: '' + form_title: source_entity_webform + form_submit_once: false + form_open_message: '' + form_close_message: '' + form_exception_message: '' + form_previous_submissions: false + form_confidential: false + form_confidential_message: '' + form_disable_remote_addr: false + form_convert_anonymous: false + form_prepopulate: true + form_prepopulate_source_entity: false + form_prepopulate_source_entity_required: false + form_prepopulate_source_entity_type: '' + form_unsaved: false + form_disable_back: false + form_submit_back: false + form_disable_autocomplete: false + form_novalidate: false + form_disable_inline_errors: false + form_required: false + form_autofocus: false + form_details_toggle: false + form_reset: false + form_access_denied: default + form_access_denied_title: '' + form_access_denied_message: '' + form_access_denied_attributes: { } + form_file_limit: '' + form_attributes: { } + form_method: '' + form_action: '' + share: false + share_node: false + share_theme_name: '' + share_title: true + share_page_body_attributes: { } + submission_label: '' + submission_exception_message: '' + submission_locked_message: '' + submission_log: false + submission_excluded_elements: + happy: happy + submission_exclude_empty: true + submission_exclude_empty_checkbox: true + submission_views: { } + submission_views_replace: { } + submission_user_columns: { } + submission_user_duplicate: false + submission_access_denied: default + submission_access_denied_title: '' + submission_access_denied_message: '' + submission_access_denied_attributes: { } + previous_submission_message: '' + previous_submissions_message: '' + autofill: false + autofill_message: '' + autofill_excluded_elements: { } + wizard_progress_bar: false + wizard_progress_pages: false + wizard_progress_percentage: false + wizard_progress_link: false + wizard_progress_states: false + wizard_start_label: '' + wizard_preview_link: false + wizard_confirmation: true + wizard_confirmation_label: '' + wizard_auto_forward: true + wizard_auto_forward_hide_next_button: false + wizard_keyboard: true + wizard_track: '' + wizard_prev_button_label: '' + wizard_next_button_label: '' + wizard_toggle: false + wizard_toggle_show_label: '' + wizard_toggle_hide_label: '' + wizard_page_type: container + wizard_page_title_tag: h2 + preview: 0 + preview_label: '' + preview_title: '' + preview_message: '' + preview_attributes: { } + preview_excluded_elements: { } + preview_exclude_empty: true + preview_exclude_empty_checkbox: false + draft: none + draft_multiple: false + draft_auto_save: false + draft_saved_message: '' + draft_loaded_message: '' + draft_pending_single_message: '' + draft_pending_multiple_message: '' + confirmation_type: inline + confirmation_url: '' + confirmation_title: '' + confirmation_message: 'Thank you for your feedback !' + confirmation_attributes: { } + confirmation_back: false + confirmation_back_label: '' + confirmation_back_attributes: { } + confirmation_exclude_query: false + confirmation_exclude_token: false + confirmation_update: false + limit_total: null + limit_total_interval: null + limit_total_message: '' + limit_total_unique: false + limit_user: null + limit_user_interval: null + limit_user_message: '' + limit_user_unique: false + entity_limit_total: null + entity_limit_total_interval: null + entity_limit_user: null + entity_limit_user_interval: null + purge: none + purge_days: null + results_disabled: false + results_disabled_ignore: false + results_customize: false + token_view: false + token_update: false + token_delete: false + serial_disabled: false +access: + create: + roles: + - anonymous + - authenticated + users: { } + permissions: { } + view_any: + roles: { } + users: { } + permissions: { } + update_any: + roles: { } + users: { } + permissions: { } + delete_any: + roles: { } + users: { } + permissions: { } + purge_any: + roles: { } + users: { } + permissions: { } + view_own: + roles: { } + users: { } + permissions: { } + update_own: + roles: { } + users: { } + permissions: { } + delete_own: + roles: { } + users: { } + permissions: { } + administer: + roles: { } + users: { } + permissions: { } + test: + roles: { } + users: { } + permissions: { } + configuration: + roles: { } + users: { } + permissions: { } +handlers: + post_to_bigquery: + id: bigquery_form_handler + handler_id: post_to_bigquery + label: 'Post to BigQuery' + notes: '' + status: true + conditions: { } + weight: 0 + settings: + service_account: '1' + project: '738313172788' + dataset: ai-search-boston-gov-91793.AISearchFeedback + table: mytesttable +variants: { } diff --git a/docroot/modules/custom/bos_components/modules/bos_aws_services/bos_aws_services.info.yml b/docroot/modules/custom/bos_components/modules/bos_aws_services/bos_aws_services.info.yml new file mode 100644 index 0000000000..4e1f70661c --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_aws_services/bos_aws_services.info.yml @@ -0,0 +1,8 @@ +name: 'AWS GenAI Services Integration' +type: module +description: 'Adds plugins and Drupal API for AWS-Kendra GenAI applications.' +core_version_requirement: ^10 +package: 'Custom' +dependencies: + - bos_search +config_devel: { } diff --git a/docroot/modules/custom/bos_components/modules/bos_aws_services/bos_aws_services.module b/docroot/modules/custom/bos_components/modules/bos_aws_services/bos_aws_services.module new file mode 100644 index 0000000000..c4a0fbb2c8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_aws_services/bos_aws_services.module @@ -0,0 +1,9 @@ +kendra = \Drupal::getContainer()->get("bos_aws_services.kendra"); + } + + /** + * @param \Drupal\bos_search\AiSearchRequest $request + * @param bool $fake * + * +* @inheritDoc + */ + public function search(AiSearchRequest $request, bool $fake = FALSE): AiSearchResponse { + try { + + $this->kendra->execute([ + "text" => $request->get("search_text"), + "conversation_id" => $request->get("conversation_id") ?? "", + ]); + $result = $this->kendra->getResults(); + } + catch (\Exception $e) { + $result = FALSE; + } + + // Load the GcSearchConversationResponse into the AiSearchResponse fmt. + if ($result) { + $response = new AiSearchResponse($request, $result['ai_answer'], $result['conversation_id']); + $response->set("body", $result['body']) + ->set("citations", $result['citations']) + ->set("metadata", $result['metadata']) + ->set("references", $result['references']); + foreach($result['search_results'] as $search_result) { + // Load each search result into the AiSearchResult format. + $res = new AiSearchResult($search_result["title"], $search_result["link"], $search_result["summary"]); + $res->set("id", $search_result["id"]) + ->set("link_title", $search_result["link_title"]) + ->set("ref", $search_result["ref"]); + $response->addResult($res); + } + $request->addHistory($response); + $response->set("search", $request); + } + + return $response; + } + + /** + * @inheritDoc + */ + public function hasFollowUp(): bool { + return $this->kendra->hasFollowup(); + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + // TODO: Implement availablePrompts() method. + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_aws_services/src/Services/AwsKendraService.php b/docroot/modules/custom/bos_components/modules/bos_aws_services/src/Services/AwsKendraService.php new file mode 100644 index 0000000000..0aaa958ea3 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_aws_services/src/Services/AwsKendraService.php @@ -0,0 +1,76 @@ +log = $logger->get('bos_aws_services'); + $this->config = $config->get("bos_aws_service.settings") ?? []; + + // Do the CuRL initialization in BosCurlControllerBase. + parent::__construct(); + + } + /** + * @inheritDoc + */ + public static function id(): string { + return "kendra"; + } + + /** + * @inheritDoc + */ + public function execute(array $parameters = []): string { + // TODO: Implement execute() method. + return "OK"; + } + + /** + * @inheritDoc + */ + public function buildForm(array &$form): void { + // TODO: Implement buildForm() method. + } + + /** + * @inheritDoc + */ + public function submitForm(array $form, FormStateInterface $form_state): void { + // TODO: Implement submitForm() method. + } + + /** + * @inheritDoc + */ + public function validateForm(array $form, FormStateInterface &$form_state): void { + // TODO: Implement validateForm() method. + } + + /** + * @inheritDoc + */ + public function setServiceAccount(string $service_account): AwsKendraService { + // TODO: Implement setServiceAccount() method. + return $this; + } + + /** + * @inheritDoc + */ + public function hasFollowup(): bool { + // TODO check if this is set true from config form. + return TRUE; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/bos_gc_aisearch_plugin.info.yml b/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/bos_gc_aisearch_plugin.info.yml new file mode 100644 index 0000000000..186bf14bf8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/bos_gc_aisearch_plugin.info.yml @@ -0,0 +1,8 @@ +name: 'Google Cloud AiSearch Plugin' +type: module +description: 'Plugin so that AiSearch (bos_search) can use google_cloud services.' +core_version_requirement: ^10 +package: 'Custom' +dependencies: + - bos_search +config_devel: { } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/bos_gc_aisearch_plugin.module b/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/bos_gc_aisearch_plugin.module new file mode 100644 index 0000000000..2b1c6051b5 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/bos_gc_aisearch_plugin.module @@ -0,0 +1,9 @@ +get("preset") ?? []; + if ($fake) { + $result = $this->fakeResponse(); + if (empty($request->get("session_id"))) { + $result["session_id"] = rand(10000000,99999999); + } + else { + $result["session_id"] = $request->get("session_id"); + } + } + else { + $parameters = [ + "text" => $request->get("search_text") ?? "", + "allow_conversation" => $preset["searchform"]["searchbar"]["allow_conversation"] ?? FALSE, + "session_id" => $request->get("session_id") ?? "", + "prompt" => $preset["prompt"] ?? 'default', + "extra_prompt" => 'If you cannot understand the question or the question cannot be answered, respond with the text "' . self::NO_RESULTS . '"', + "metadata" => $preset["results"]["metadata"] ?? 0, + "num_results" => $preset["results"]["result_count"] ?? 0, + "include_citations" => $preset["results"]["citations"] ?? 0, + "safe_search" => $preset["model_tuning"]['search']["safe_search"] ?? 0, + "ignoreAdversarialQuery" => $preset["model_tuning"]['summary']["ignoreAdversarialQuery"] ?? 0, + "ignoreNonSummarySeekingQuery" => $preset["model_tuning"]['summary']["ignoreNonSummarySeekingQuery"] ?? 0, + "ignoreLowRelevantContent" => $preset["model_tuning"]['summary']["ignoreLowRelevantContent"] ?? 0, + "ignoreJailBreakingQuery" => $preset["model_tuning"]['summary']["ignoreJailBreakingQuery"] ?? 0, + "semantic_chunks" => $preset["model_tuning"]['summary']["semantic_chunks"] ?? 0, + ]; + // Apply any service overrides. + if (!empty($preset["model_tuning"]["overrides"]["service_account"]) && $preset["model_tuning"]["overrides"]["service_account"] != "default") { + $parameters["service_account"] = $preset["model_tuning"]["overrides"]["service_account"]; + $this->service->setServiceAccount($parameters["service_account"]); + } + if (!empty($preset["model_tuning"]["overrides"]["project_id"]) && $preset["model_tuning"]["overrides"]["project_id"] != "default") { + $parameters["project_id"] = $preset["model_tuning"]["overrides"]["project_id"]; + } + if (!empty($preset["model_tuning"]["overrides"]["datastore_id"]) && $preset["model_tuning"]["overrides"]["datastore_id"] != "default") { + $parameters["datastore_id"] = $preset["model_tuning"]["overrides"]["datastore_id"]; + } + $this->service->execute($parameters); + $result = $this->service->getResults(); + } + } + catch (\Exception $e) { + $result = FALSE; + } + + // Load the GcSearchConversationResponse into the AiSearchResponse fmt. + if ($result) { + + // Check for no-results response. + $response = new AiSearchResponse($request, $result['body'], $result['session_id'] ?? ""); + if (trim($result['body']) == self::NO_RESULTS) { + $response->set("no_results", TRUE); + $response->set("metadata", $this->flattenMetadata($result["metadata"], $preset)); + } + elseif (!empty($result['violations'])) { + $response->set("violations", $result['violations']); + $response->set("metadata", $this->flattenMetadata($result["metadata"])); + } + else { + $response->set("body", $result['body']) + ->set("metadata", $this->flattenMetadata($result["metadata"], $preset)); +// ->set("citations", $result['citations'] ?? []); + foreach ($result['search_results'] as $search_result) { + + if (!$preset["results"]["no_dup_citations"] || !$search_result["is_citation"]) { + // Load each search result into the AiSearchResult format. + $res = new AiSearchResult($search_result["title"], $search_result["link"], $search_result["snippet"]); + $res->set("id", $search_result["id"]) + ->set("link_title", $search_result["link_title"]) + ->set("content", $search_result["content"]) + ->set("description", $search_result["description"] ?? "") + ->set("ref", $search_result["ref"]); + $response->addResult($res); + } + } + $response->set("search", $request); + } + } + + return $response; + } + + /** + * @inheritDoc + */ + public function hasFollowUp(): bool { + return $this->service->hasFollowup(); + } + + /** + * Generates a fake response for testing purposes. + * + * This method creates a simulated response to be used when the actual response + * from an external source is not available or when testing functionalities + * without making real external requests. + * + * @return array An associative array representing a fake response, including + * necessary fields such as 'session_id' and other relevant + * placeholders required by the system. + */ + private function fakeResponse() { + $a = base64_decode("YTo2OntzOjQ6ImJvZHkiO3M6OTExOiIqKiBDQUNIRUQgKiogVG8gZ2V0IGEgZm9vZCB0cnVjayBwZXJtaXQgaW4gQm9zdG9uLCB5b3UnbGwgbmVlZCBzZXZlcmFsIGRvY3VtZW50cywgaW5jbHVkaW5nIHBlcm1pdHMgZnJvbSBJbnNwZWN0aW9uYWwgU2VydmljZXMgYW5kIEZpcmUsIGEgSGF3a2VyIGFuZCBQZWRkbGVyIExpY2Vuc2UsIGEgQnVzaW5lc3MgQ2VydGlmaWNhdGUsIGEgQ2VydGlmaWNhdGUgb2YgTGlhYmlsaXR5IEluc3VyYW5jZSwgYSBidXNpbmVzcyBwbGFuLCBhbmQgYSB3cml0dGVuIGFncmVlbWVudCBmcm9tIHlvdXIgY29tbWlzc2FyeS4gWzFdIFlvdSdsbCBhbHNvIG5lZWQgYSBmaXJlIHN1cHByZXNzaW9uIHN5c3RlbSBhbmQgYSBmaXJlIGV4dGluZ3Vpc2hlciBmb3IgeW91ciB0cnVjay4gWzJdIE9uY2UgeW91ciB0cnVjayBwYXNzZXMgdGhlIGZpcmUgaW5zcGVjdGlvbiwgdGhlIGZpcmUgaW5zcGVjdG9yIHdpbGwgc2lnbiB5b3VyIGZvb2QgdHJ1Y2sgcGVybWl0IGFwcGxpY2F0aW9uLiBbMl0gIFlvdSdsbCBuZWVkIHRvIGdldCBwZXJtaXNzaW9uIGZyb20gdGhlIHByb3BlcnR5IG93bmVyIGlmIHlvdSBwbGFuIHRvIG9wZXJhdGUgaW4gYSBwcml2YXRlIGxvY2F0aW9uLiBbMV0gIEZpbmFsbHksIHlvdSdsbCBuZWVkIHRvIHN1Ym1pdCB5b3VyIGZvb2QgdHJ1Y2sgcGVybWl0IGFwcGxpY2F0aW9uLCBhIGxlYXNlIG9yIGxldHRlciBvZiBhZ3JlZW1lbnQgZnJvbSB0aGUgcHJvcGVydHkgb3duZXIsIGEgcGhvdG8gb2YgdGhlIHNpdGUsIGEgZHJhd2luZyBzaG93aW5nIHdoZXJlIHlvdXIgdHJ1Y2sgd2lsbCBiZSBsb2NhdGVkIGFuZCBob3cgeW91IHBsYW4gdG8gc2VydmUgZm9vZCwgYW5kIGEgY29weSBvZiB5b3VyIFVzZSBvZiBQcmVtaXNlcyBwZXJtaXQgYXBwcm92ZWQgYnkgSW5zcGVjdGlvbmFsIFNlcnZpY2VzLiBbNV0gCiI7czo4OiJtZXRhZGF0YSI7YTo0OntzOjc6IlJlcXVlc3QiO2E6ODp7czo0OiJUZXh0IjthOjI6e3M6Mzoia2V5IjtzOjQ6InRleHQiO3M6NToidmFsdWUiO3M6MzM6IkhvdyBkbyBJIGdldCBhIGZvb2QgdHJ1Y2sgcGVybWl0PyI7fXM6NjoiUHJvbXB0IjthOjI6e3M6Mzoia2V5IjtzOjY6InByb21wdCI7czo1OiJ2YWx1ZSI7czo4OiJhaVNlYXJjaCI7fXM6ODoiTWV0YWRhdGEiO2E6Mjp7czozOiJrZXkiO3M6ODoibWV0YWRhdGEiO3M6NToidmFsdWUiO3M6MToiMSI7fXM6MTE6Ik51bSBSZXN1bHRzIjthOjI6e3M6Mzoia2V5IjtzOjExOiJudW1fcmVzdWx0cyI7czo1OiJ2YWx1ZSI7czoxOiI1Ijt9czoxNzoiSW5jbHVkZSBDaXRhdGlvbnMiO2E6Mjp7czozOiJrZXkiO3M6MTc6ImluY2x1ZGVfY2l0YXRpb25zIjtzOjU6InZhbHVlIjtzOjE6IjEiO31zOjExOiJTYWZlIFNlYXJjaCI7YToyOntzOjM6ImtleSI7czoxMToic2FmZV9zZWFyY2giO3M6NToidmFsdWUiO3M6MToiMSI7fXM6MTU6IlNlbWFudGljIENodW5rcyI7YToyOntzOjM6ImtleSI7czoxNToic2VtYW50aWNfY2h1bmtzIjtzOjU6InZhbHVlIjtpOjA7fXM6NToiTW9kZWwiO2E6Mjp7czozOiJrZXkiO3M6NToibW9kZWwiO3M6NToidmFsdWUiO3M6Njoic3RhYmxlIjt9fXM6MTI6Ik1vZGVsIENvbmZpZyI7YTo2OntzOjEwOiJQcm9qZWN0IElkIjthOjI6e3M6Mzoia2V5IjtzOjEwOiJwcm9qZWN0X2lkIjtzOjU6InZhbHVlIjtzOjEyOiI2MTIwNDI2MTI1ODgiO31zOjEyOiJEYXRhc3RvcmUgSWQiO2E6Mjp7czozOiJrZXkiO3M6MTI6ImRhdGFzdG9yZV9pZCI7czo1OiJ2YWx1ZSI7czoyNzoiZHJ1cGFsd2Vic2l0ZV8xNzAyOTE5MTE5NzY4Ijt9czoxMToiTG9jYXRpb24gSWQiO2E6Mjp7czozOiJrZXkiO3M6MTE6ImxvY2F0aW9uX2lkIjtzOjU6InZhbHVlIjtzOjY6Imdsb2JhbCI7fXM6ODoiRW5kcG9pbnQiO2E6Mjp7czozOiJrZXkiO3M6ODoiZW5kcG9pbnQiO3M6NToidmFsdWUiO3M6Mzg6Imh0dHBzOi8vZGlzY292ZXJ5ZW5naW5lLmdvb2dsZWFwaXMuY29tIjt9czoxNToiU2VydmljZSBBY2NvdW50IjthOjI6e3M6Mzoia2V5IjtzOjE1OiJzZXJ2aWNlX2FjY291bnQiO3M6NToidmFsdWUiO3M6MTc6InNlcnZpY2VfYWNjb3VudF8xIjt9czoxODoiQWxsb3cgQ29udmVyc2F0aW9uIjthOjI6e3M6Mzoia2V5IjtzOjE4OiJhbGxvd19jb252ZXJzYXRpb24iO3M6NToidmFsdWUiO2k6MTt9fXM6MTE6Ik1vZGVsIFN0YXRlIjthOjE6e3M6Mjc6IkN1cnJlbnQgQ29udmVyc2F0aW9uIExlbmd0aCI7YToyOntzOjM6ImtleSI7czoxOToiY29udmVyc2F0aW9uX2xlbmd0aCI7czo1OiJ2YWx1ZSI7aToxO319czoxNDoiTW9kZWwgUmVzcG9uc2UiO2E6ODp7czo4OiJFbmRwb2ludCI7YToyOntzOjM6ImtleSI7czoyMToiY29udmVyc2F0aW9uX2VuZHBvaW50IjtzOjU6InZhbHVlIjtzOjE4MDoiaHR0cHM6Ly9kaXNjb3ZlcnllbmdpbmUuZ29vZ2xlYXBpcy5jb20vdjFhbHBoYS9wcm9qZWN0cy82MTIwNDI2MTI1ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9kcnVwYWx3ZWJzaXRlXzE3MDI5MTkxMTk3NjgvY29udmVyc2F0aW9ucy8tOmNvbnZlcnNlIjt9czoxMjoiQ29udmVyc2F0aW9uIjthOjI6e3M6Mzoia2V5IjtzOjE3OiJjb252ZXJzYXRpb25fbmFtZSI7czo1OiJ2YWx1ZSI7czoxNDI6InByb2plY3RzLzYxMjA0MjYxMjU4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL2RydXBhbHdlYnNpdGVfMTcwMjkxOTExOTc2OC9jb252ZXJzYXRpb25zLzM2NDk1NDA3NDQ0NTIwNzE2MzEiO31zOjU6IlN0YXRlIjthOjI6e3M6Mzoia2V5IjtzOjE4OiJjb252ZXJzYXRpb25fc3RhdGUiO3M6NToidmFsdWUiO3M6MTE6IklOX1BST0dSRVNTIjt9czo4OiJQc2V1ZG9JZCI7YToyOntzOjM6ImtleSI7czoxNjoiY29udmVyc2F0aW9uX3JlZiI7czo1OiJ2YWx1ZSI7czoxOToiMzY0OTU0MDc0NDQ1MjA3MTYzMSI7fXM6MTg6IkRydXBhbCBJbnRlcm5hbCBJZCI7YToyOntzOjM6ImtleSI7czoxNToiY29udmVyc2F0aW9uX2lkIjtzOjU6InZhbHVlIjtzOjA6IiI7fXM6MTQ6IlF1ZXJ5IER1cmF0aW9uIjthOjI6e3M6Mzoia2V5IjtzOjI3OiJjb252ZXJzYXRpb25fcXVlcnlfZHVyYXRpb24iO3M6NToidmFsdWUiO2Q6My42Njc0MTM5NDk5NjY0MzA3O31zOjIzOiJTZWFyY2ggUmVzdWx0cyBSZXR1cm5lZCI7YToyOntzOjM6ImtleSI7czoxNDoicmVzdWx0c19sZW5ndGgiO3M6NToidmFsdWUiO2k6MTA7fXM6MTg6IkNpdGF0aW9ucyBSZXR1cm5lZCI7YToyOntzOjM6ImtleSI7czoxNjoiY2l0YXRpb25zX2xlbmd0aCI7czo1OiJ2YWx1ZSI7aTo0O319fXM6MTQ6InNlYXJjaF9yZXN1bHRzIjthOjg6e2k6MDthOjc6e3M6NzoiY29udGVudCI7czo3NDE6IlByaXZhdGUgbG9jYXRpb25zOiBZb3UgbmVlZCBwZXJtaXNzaW9uIGZyb20gdGhlIHByb3BlcnR5IG93bmVyLiBFYWNoIHR5cGUgb2YgbG9jYXRpb24gaGFzIGl0cyBvd24gcnVsZXMgYW5kIGxpY2Vuc2luZy4gUGxlYXNlIGdpdmUgdXMgdHdvIHdlZWtzIHRvIGFwcHJvdmUgeW91ciBwZXJtaXQuIFlvdSBjYW4gcGljayB1cCB5b3VyIHBlcm1pdCBhdCBQdWJsaWMgV29ya3M6IDEgQ2l0eSBIYWxsIFNxdWFyZSwgUm9vbSA3MTQgQm9zdG9uIENpdHkgSGFsbCwgQm9zdG9uLCBNQSAwMjIwMSBPZmZpY2UgaG91cnM6IE1vbmRheSB0aHJvdWdoIEZyaWRheSwgOSBhbSAtIDUgcG0gV2hlbiB5b3VyIGZvb2QgdHJ1Y2sgcGVybWl0IGlzIGFwcHJvdmVkLCB3ZSYjMzk7bGwgbGV0IHlvdSBrbm93IHdoZW4gd2UgaG9sZCB0aGUgbmV4dCBwdWJsaWMgc2l0ZSBsb3R0ZXJ5LiBzaG93IGhpZGUgQnkgZW1haWwgU3RlcCAxIEJlZm9yZSB5b3UgZ2V0IHN0YXJ0ZWQgYnkgZW1haWwgQmVmb3JlIHlvdSBjYW4gYXBwbHkgZm9yIGEgZm9vZCB0cnVjayBwZXJtaXQgeW91IG5lZWQ6IHBlcm1pdHMgZnJvbSBJbnNwZWN0aW9uYWwgU2VydmljZXMgYW5kIEZpcmUgYSBIYXdrZXIgYW5kIFBlZGRsZXIgTGljZW5zZSBhIEJ1c2luZXNzIENlcnRpZmljYXRlIGEgQ2VydGlmaWNhdGUgb2YgTGlhYmlsaXR5IEluc3VyYW5jZSBhIGJ1c2luZXNzIHBsYW4sIGFuZCBhIHdyaXR0ZW4gYWdyZWVtZW50IGZyb20geW91ciBjb21taXNzYXJ5LiI7czoyOiJpZCI7czozMjoiMDk1YWU0MjYwNjc3YjZjZTQ1ZTM1NzM1MGQzMWU2YTkiO3M6NDoibGluayI7czo4NzoiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9zbWFsbC1idXNpbmVzcy1kZXZlbG9wbWVudC9ob3ctZ2V0LWZvb2QtdHJ1Y2stcGVybWl0IjtzOjEwOiJsaW5rX3RpdGxlIjtzOjE0OiJ3d3cuYm9zdG9uLmdvdiI7czozOiJyZWYiO3M6MTYyOiJwcm9qZWN0cy82MTIwNDI2MTI1ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9kcnVwYWx3ZWJzaXRlXzE3MDI5MTkxMTk3NjgvYnJhbmNoZXMvMC9kb2N1bWVudHMvMDk1YWU0MjYwNjc3YjZjZTQ1ZTM1NzM1MGQzMWU2YTkiO3M6Nzoic3VtbWFyeSI7czo2MToiVGhlcmUgYXJlIHRocmVlIHdheXMgdG8gYXBwbHkgZm9yIGEgPGI+Zm9vZCB0cnVjayBwZXJtaXQ8L2I+LiI7czo1OiJ0aXRsZSI7czo0MzoiSG93IHRvIGdldCBhIGZvb2QgdHJ1Y2sgcGVybWl0IHwgQm9zdG9uLmdvdiI7fWk6MTthOjc6e3M6NzoiY29udGVudCI7czoyNzQ6IlRvIGdldCB0aGUgcGVybWl0LCB5b3UmIzM5O2xsIG5lZWQgYSBmaXJlIHN1cHByZXNzaW9uIHN5c3RlbSBhbmQgYSBmaXJlIGV4dGluZ3Vpc2hlci4gWW91JiMzOTtsbCBoYXZlIHRvIHJlbmV3IHRoaXMgcGVybWl0IGV2ZXJ5IHllYXIuIElmIHlvdXIgdHJ1Y2sgcGFzc2VzIHRoZSBmaXJlIGluc3BlY3Rpb24sIHlvdSYjMzk7bGwgYmUgZ2l2ZW4gYSBwZXJtaXQgYW5kIHRoZSBmaXJlIGluc3BlY3RvciB3aWxsIHNpZ24geW91ciBmb29kIHRydWNrIHBlcm1pdCBhcHBsaWNhdGlvbi4iO3M6MjoiaWQiO3M6MzI6IjJhMTc3MjE2ZTA4NTFiMTBkMjdmOGMzOGJmZmI1YzUyIjtzOjQ6ImxpbmsiO3M6MTA4OiJodHRwczovL3d3dy5ib3N0b24uZ292L2RlcGFydG1lbnRzL3NtYWxsLWJ1c2luZXNzLWRldmVsb3BtZW50L2hvdy1nZXQtaGVhbHRoLWFuZC1maXJlLXBlcm1pdC15b3VyLWZvb2QtdHJ1Y2siO3M6MTA6ImxpbmtfdGl0bGUiO3M6MTQ6Ind3dy5ib3N0b24uZ292IjtzOjM6InJlZiI7czoxNjI6InByb2plY3RzLzYxMjA0MjYxMjU4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL2RydXBhbHdlYnNpdGVfMTcwMjkxOTExOTc2OC9icmFuY2hlcy8wL2RvY3VtZW50cy8yYTE3NzIxNmUwODUxYjEwZDI3ZjhjMzhiZmZiNWM1MiI7czo3OiJzdW1tYXJ5IjtzOjE4OToiU2NoZWR1bGUgYW4gaW5zcGVjdGlvbiAmbWlkZG90OyBPbmNlIHlvdXIgPGI+dHJ1Y2s8L2I+IGlzIGNvbXBsZXRlLCB5b3UgbmVlZCB0byA8Yj5nZXQ8L2I+IGl0IGluc3BlY3RlZCBiZWZvcmUgeW91IGNhbiBydW4geW91ciBidXNpbmVzcy4gJm1pZGRvdDsgQ2FsbCB0aGUgRmlyZSBEZXBhcnRtZW50IGZpcnN0IHRvJm5ic3A7Li4uIjtzOjU6InRpdGxlIjtzOjY4OiJIb3cgdG8gZ2V0IGEgSGVhbHRoIGFuZCBGaXJlIHBlcm1pdCBmb3IgeW91ciBmb29kIHRydWNrIHwgQm9zdG9uLmdvdiI7fWk6MjthOjc6e3M6NzoiY29udGVudCI7czo1MDg6ItCe0LTQvdCw0LrQviDQstGLINC80L7QttC10YLQtSDRgdC+0L7QsdGJ0LDRgtGMINC+INC90LXQv9GA0LDQstC40LvRjNC90YvRhSDQuNC70Lgg0L3QtdC60LDRh9C10YHRgtCy0LXQvdC90YvRhSDQv9C10YDQtdCy0L7QtNCw0YUg0Lgg0LLQvdC+0YHQuNGC0Ywg0LHQvtC70LXQtSDQutCw0YfQtdGB0YLQstC10L3QvdGL0LUg0L/QtdGA0LXQstC+0LTRiyDRgSDQv9C+0LzQvtGJ0YzRjiBHb29nbGUgVHJhbnNsYXRlLiDQodC90LDRh9Cw0LvQsCDQvdCw0LLQtdC00LjRgtC1INC60YPRgNGB0L7RgCDQvNGL0YjQuCDQuCDRidC10LvQutC90LjRgtC1INC70Y7QsdC+0Lkg0YLQtdC60YHRgiwg0YHQvtC00LXRgNC20LDRidC40Lkg0L7RiNC40LHQutGDLiDQlNC+0LvQttC90L4g0L/QvtGP0LLQuNGC0YzRgdGPINCy0YHQv9C70YvQstCw0Y7RidC10LUg0L7QutC90L4uINCU0LDQu9C10LUg0L3QsNC20LzQuNGC0LUg4oCc0JLQvdC10YHRgtC4INC70YPRh9GI0LjQuSDQv9C10YDQtdCy0L7QtOKAnC4iO3M6MjoiaWQiO3M6MzI6ImQ3ZGFmNjg0NDA0MjNlYmQ1ODNlYTcyMzVhNTMyODFiIjtzOjQ6ImxpbmsiO3M6NzM6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvc21hbGwtYnVzaW5lc3MtZGV2ZWxvcG1lbnQvZm9vZC10cnVja3MiO3M6MTA6ImxpbmtfdGl0bGUiO3M6MTQ6Ind3dy5ib3N0b24uZ292IjtzOjM6InJlZiI7czoxNjI6InByb2plY3RzLzYxMjA0MjYxMjU4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL2RydXBhbHdlYnNpdGVfMTcwMjkxOTExOTc2OC9icmFuY2hlcy8wL2RvY3VtZW50cy9kN2RhZjY4NDQwNDIzZWJkNTgzZWE3MjM1YTUzMjgxYiI7czo3OiJzdW1tYXJ5IjtzOjE5NzoiLi4uIG9mIGZvb2QgdHJ1Y2tzIGluIHRoZSBDaXR5IG9mIEJvc3RvbjogRm9vZCB0cnVja3MgbWFwLiBXaGVyZSB0byBzdGFydC4gPGI+SG93IHRvIGdldCBhIGZvb2QgdHJ1Y2sgcGVybWl0PC9iPiAmbWlkZG90OyBGb29kIHRydWNrIGxvdHRlcnkgJm1pZGRvdDsgRm9vZCB0cnVjayBncmFkaW5nICZtaWRkb3Q7IEZvb2QgdHJ1Y2smbmJzcDsuLi4iO3M6NToidGl0bGUiO3M6MjQ6IkZvb2QgVHJ1Y2tzIHwgQm9zdG9uLmdvdiI7fWk6MzthOjc6e3M6NzoiY29udGVudCI7czoyOTQ6IldlJiMzOTtsbCBuZWVkOiB5b3VyIGZvb2QgdHJ1Y2sgcGVybWl0IGFwcGxpY2F0aW9uIHRoZSBsZWFzZSBvciBsZXR0ZXIgb2YgYWdyZWVtZW50IGZyb20gdGhlIHByb3BlcnR5IG93bmVyIGEgcGhvdG8gb2YgdGhlIHNpdGUgYW5kIGEgZHJhd2luZyB0aGF0IHNob3dzIHdoZXJlIHlvdXIgdHJ1Y2sgd2lsbCBiZSBsb2NhdGVkIGFuZCBob3cgeW91IHBsYW4gdG8gc2VydmUgZm9vZCwgYW5kIGEgY29weSBvZiB5b3VyIFVzZSBvZiBQcmVtaXNlcyBwZXJtaXQgYXBwcm92ZWQgYnkgSW5zcGVjdGlvbmFsIFNlcnZpY2VzLiI7czoyOiJpZCI7czozMjoiOGMyMDc3OTZmZjVjNTBhNWM5NzMzZWM3YWMzMzQ4ZTMiO3M6NDoibGluayI7czo3ODoiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9zbWFsbC1idXNpbmVzcy1kZXZlbG9wbWVudC9mb29kLXRydWNrLXNpdGVzIjtzOjEwOiJsaW5rX3RpdGxlIjtzOjE0OiJ3d3cuYm9zdG9uLmdvdiI7czozOiJyZWYiO3M6MTYyOiJwcm9qZWN0cy82MTIwNDI2MTI1ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9kcnVwYWx3ZWJzaXRlXzE3MDI5MTkxMTk3NjgvYnJhbmNoZXMvMC9kb2N1bWVudHMvOGMyMDc3OTZmZjVjNTBhNWM5NzMzZWM3YWMzMzQ4ZTMiO3M6Nzoic3VtbWFyeSI7czoxOTI6IldlIGJhc2UgbW9udGhseSA8Yj5wZXJtaXQ8L2I+IGZlZXMgb24gaG93IG1hbnkgc2hpZnRzIHlvdSB3b3JrIGF0IGVhY2ggc2l0ZS4gWW91JiMzOTtsbCA8Yj5oYXZlPC9iPiB0byBwYXkgdGhlIGZlZSBmb3IgdGhlIG51bWJlciBvZiBzaGlmdHMgaW4gZWFjaCB6b25lIHRoYXQgeW91IDxiPmhhdmU8L2I+LiBZb3UgbXVzdCZuYnNwOy4uLiI7czo1OiJ0aXRsZSI7czoyOToiRm9vZCB0cnVjayBzaXRlcyB8IEJvc3Rvbi5nb3YiO31pOjQ7YTo3OntzOjc6ImNvbnRlbnQiO3M6NTA4OiLQntC00L3QsNC60L4g0LLRiyDQvNC+0LbQtdGC0LUg0YHQvtC+0LHRidCw0YLRjCDQviDQvdC10L/RgNCw0LLQuNC70YzQvdGL0YUg0LjQu9C4INC90LXQutCw0YfQtdGB0YLQstC10L3QvdGL0YUg0L/QtdGA0LXQstC+0LTQsNGFINC4INCy0L3QvtGB0LjRgtGMINCx0L7Qu9C10LUg0LrQsNGH0LXRgdGC0LLQtdC90L3Ri9C1INC/0LXRgNC10LLQvtC00Ysg0YEg0L/QvtC80L7RidGM0Y4gR29vZ2xlIFRyYW5zbGF0ZS4g0KHQvdCw0YfQsNC70LAg0L3QsNCy0LXQtNC40YLQtSDQutGD0YDRgdC+0YAg0LzRi9GI0Lgg0Lgg0YnQtdC70LrQvdC40YLQtSDQu9GO0LHQvtC5INGC0LXQutGB0YIsINGB0L7QtNC10YDQttCw0YnQuNC5INC+0YjQuNCx0LrRgy4g0JTQvtC70LbQvdC+INC/0L7Rj9Cy0LjRgtGM0YHRjyDQstGB0L/Qu9GL0LLQsNGO0YnQtdC1INC+0LrQvdC+LiDQlNCw0LvQtdC1INC90LDQttC80LjRgtC1IOKAnNCS0L3QtdGB0YLQuCDQu9GD0YfRiNC40Lkg0L/QtdGA0LXQstC+0LTigJwuIjtzOjI6ImlkIjtzOjMyOiI3NmMxMzMwY2I1ZTc0ZGNlOGVkMTIyY2RiYjdmMWI2NSI7czo0OiJsaW5rIjtzOjg4OiJodHRwczovL3d3dy5ib3N0b24uZ292L2RlcGFydG1lbnRzL2Vjb25vbWljLWRldmVsb3BtZW50L2Zvb2QtdHJ1Y2stcnVsZXMtYW5kLXJlZ3VsYXRpb25zIjtzOjEwOiJsaW5rX3RpdGxlIjtzOjE0OiJ3d3cuYm9zdG9uLmdvdiI7czozOiJyZWYiO3M6MTYyOiJwcm9qZWN0cy82MTIwNDI2MTI1ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9kcnVwYWx3ZWJzaXRlXzE3MDI5MTkxMTk3NjgvYnJhbmNoZXMvMC9kb2N1bWVudHMvNzZjMTMzMGNiNWU3NGRjZThlZDEyMmNkYmI3ZjFiNjUiO3M6Nzoic3VtbWFyeSI7czoxNzk6IlZlbmRpbmcgd2l0aG91dCBhbiBBbm51YWwgPGI+Rm9vZCBUcnVjayBQZXJtaXQ8L2I+IGlzIHByb2hpYml0ZWQgYnkgdGhlIENpdHkgb2YgQm9zdG9uIGZvciBhbGwgbW9iaWxlIGJ1c2luZXNzZXMuIC4uLiA8Yj5Gb29kIHRydWNrcyBoYXZlPC9iPiBhbiBvcHBvcnR1bml0eSB0byBkcm9wIHNpdGVzJm5ic3A7Li4uIjtzOjU6InRpdGxlIjtzOjMxOiJGb29kIHRydWNrIGxvdHRlcnkgfCBCb3N0b24uZ292Ijt9aTo1O2E6Nzp7czo3OiJjb250ZW50IjtzOjM2NzoiWW91JiMzOTtsbCBuZWVkIHRvIGdpdmUgdXMgc2V2ZXJhbCBkb2N1bWVudHMsIGluY2x1ZGluZzogeW91ciBjb21wbGV0ZWQgZm9vZCBzZXJ2aWNlIHBlcm1pdCBhcHBsaWNhdGlvbiBwYXltZW50IGZvciB5b3VyIHBlcm1pdCBmZWVzIGZvdXIgc2V0cyBvZiBzaXRlIHBsYW5zIGEgY29weSBvZiB5b3VyIGVxdWlwbWVudCBzcGVjaWZpY2F0aW9ucyBmcm9tIHRoZSBtYW51ZmFjdHVyZXIgeW91ciBGb29kIFBsYW4gUmV2aWV3IFdvcmtzaGVldCBhIGNvcHkgb2YgeW91ciBtZW51IHdpdGggY29uc3VtZXIgYWR2aXNvcmllcyAoaWYgdGhleSBhcHBseSB0byB5b3UpLCBhbmQgYSBidWlsZGluZyBwZXJtaXQgc2lnbmVkIGJ5IG91ciBpbnNwZWN0b3JzLiI7czoyOiJpZCI7czozMjoiYjY1NjU5NDIxMmE2N2YyMTAxYmI5MzYyZjQ2MzhlZWUiO3M6NDoibGluayI7czo4NDoiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9pbnNwZWN0aW9uYWwtc2VydmljZXMvaG93LWdldC1mb29kLXNlcnZpY2UtcGVybWl0IjtzOjEwOiJsaW5rX3RpdGxlIjtzOjE0OiJ3d3cuYm9zdG9uLmdvdiI7czozOiJyZWYiO3M6MTYyOiJwcm9qZWN0cy82MTIwNDI2MTI1ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9kcnVwYWx3ZWJzaXRlXzE3MDI5MTkxMTk3NjgvYnJhbmNoZXMvMC9kb2N1bWVudHMvYjY1NjU5NDIxMmE2N2YyMTAxYmI5MzYyZjQ2MzhlZWUiO3M6Nzoic3VtbWFyeSI7czoxMjU6IllvdSBjYW4gYXBwbHkgZm9yIGEgPGI+cGVybWl0PC9iPiB3aGlsZSB5b3VyIGJ1c2luZXNzIGlzIGJlaW5nIGJ1aWx0LCBvciA8Yj5nZXQ8L2I+IGEgPGI+cGVybWl0PC9iPiBmb3IgYW4gZXhpc3RpbmcgYnVzaW5lc3MuIjtzOjU6InRpdGxlIjtzOjQ1OiJIb3cgdG8gZ2V0IGEgZm9vZCBzZXJ2aWNlIHBlcm1pdCB8IEJvc3Rvbi5nb3YiO31pOjY7YTo3OntzOjc6ImNvbnRlbnQiO3M6NTA4OiLQntC00L3QsNC60L4g0LLRiyDQvNC+0LbQtdGC0LUg0YHQvtC+0LHRidCw0YLRjCDQviDQvdC10L/RgNCw0LLQuNC70YzQvdGL0YUg0LjQu9C4INC90LXQutCw0YfQtdGB0YLQstC10L3QvdGL0YUg0L/QtdGA0LXQstC+0LTQsNGFINC4INCy0L3QvtGB0LjRgtGMINCx0L7Qu9C10LUg0LrQsNGH0LXRgdGC0LLQtdC90L3Ri9C1INC/0LXRgNC10LLQvtC00Ysg0YEg0L/QvtC80L7RidGM0Y4gR29vZ2xlIFRyYW5zbGF0ZS4g0KHQvdCw0YfQsNC70LAg0L3QsNCy0LXQtNC40YLQtSDQutGD0YDRgdC+0YAg0LzRi9GI0Lgg0Lgg0YnQtdC70LrQvdC40YLQtSDQu9GO0LHQvtC5INGC0LXQutGB0YIsINGB0L7QtNC10YDQttCw0YnQuNC5INC+0YjQuNCx0LrRgy4g0JTQvtC70LbQvdC+INC/0L7Rj9Cy0LjRgtGM0YHRjyDQstGB0L/Qu9GL0LLQsNGO0YnQtdC1INC+0LrQvdC+LiDQlNCw0LvQtdC1INC90LDQttC80LjRgtC1IOKAnNCS0L3QtdGB0YLQuCDQu9GD0YfRiNC40Lkg0L/QtdGA0LXQstC+0LTigJwuIjtzOjI6ImlkIjtzOjMyOiJmY2Q5YWNhNTE1M2IwMDUzMmJiNzMyZDdlNjZmMzZjMiI7czo0OiJsaW5rIjtzOjk2OiJodHRwczovL3d3dy5ib3N0b24uZ292L2RlcGFydG1lbnRzL3NtYWxsLWJ1c2luZXNzLWRldmVsb3BtZW50L2hvdy1nZXQtaGF3a2VyLWFuZC1wZWRkbGVyLWxpY2Vuc2UiO3M6MTA6ImxpbmtfdGl0bGUiO3M6MTQ6Ind3dy5ib3N0b24uZ292IjtzOjM6InJlZiI7czoxNjI6InByb2plY3RzLzYxMjA0MjYxMjU4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL2RydXBhbHdlYnNpdGVfMTcwMjkxOTExOTc2OC9icmFuY2hlcy8wL2RvY3VtZW50cy9mY2Q5YWNhNTE1M2IwMDUzMmJiNzMyZDdlNjZmMzZjMiI7czo3OiJzdW1tYXJ5IjtzOjE5MzoiQXBwbHkgd2l0aCB0aGUgc3RhdGUgJm1pZGRvdDsgTmVlZCB0byBLbm93OiBFYWNoIHdvcmtlciB3aG8gaGFuZGxlcyBtb25leSBvbiBhIDxiPmZvb2QgdHJ1Y2s8L2I+IG5lZWRzIHRvIDxiPmdldDwvYj4gYSBoYXdrZXIgYW5kIHBlZGRsZXIgPGI+bGljZW5zZTwvYj4gdGhyb3VnaCB0aGUgc3RhdGUuIFRoZXkgbmVlZCB0byZuYnNwOy4uLiI7czo1OiJ0aXRsZSI7czo1MjoiSG93IHRvIGdldCBhIGhhd2tlciBhbmQgcGVkZGxlciBsaWNlbnNlIHwgQm9zdG9uLmdvdiI7fWk6NzthOjc6e3M6NzoiY29udGVudCI7czoyMzk6Ikluc3BlY3Rpb25hbCBTZXJ2aWNlcyBEZXBhcnRtZW50IFRlbXBvcmFyeSBmb29kIHNlcnZpY2UgcGVybWl0IERvd25sb2FkIHRoZSBhcHBsaWNhdGlvbiBmb3IgYSB0ZW1wb3JhcnkgZm9vZCBzZXJ2aWNlIHBlcm1pdC4gSW5zcGVjdGlvbmFsIFNlcnZpY2VzIERlcGFydG1lbnQgUmVndWxhdGlvbnMgZm9yIGZvb2QgdHJ1Y2tzIFJldmlldyB0aGUgcmVndWxhdGlvbnMgZm9yIGZvb2QgdHJ1Y2tzIGF0IHlvdXIgZXZlbnQuIjtzOjI6ImlkIjtzOjMyOiI5ODcxMzZlMThkOGU5MjZjNTVlNmEwYTJmYTVhZWRkMyI7czo0OiJsaW5rIjtzOjk1OiJodHRwczovL3d3dy5ib3N0b24uZ292L2RlcGFydG1lbnRzL2VudGVydGFpbm1lbnQtbGljZW5zaW5nL2NvbW1vbi1wZXJtaXRzLXNwZWNpYWwtZXZlbnRzLWJvc3RvbiI7czoxMDoibGlua190aXRsZSI7czoxNDoid3d3LmJvc3Rvbi5nb3YiO3M6MzoicmVmIjtzOjE2MjoicHJvamVjdHMvNjEyMDQyNjEyNTg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2RhdGFTdG9yZXMvZHJ1cGFsd2Vic2l0ZV8xNzAyOTE5MTE5NzY4L2JyYW5jaGVzLzAvZG9jdW1lbnRzLzk4NzEzNmUxOGQ4ZTkyNmM1NWU2YTBhMmZhNWFlZGQzIjtzOjc6InN1bW1hcnkiO3M6MTk3OiJCdWlsZGluZyBhbmQgc2FmZXR5IDxiPnBlcm1pdHM8L2I+LiBXaWxsIHlvdXIgZXZlbnQgPGI+aGF2ZTwvYj4gYSB0ZW50LCBzdGFnZSwgdGVtcG9yYXJ5IHN0cnVjdHVyZSwgc3BlY2lhbCBlZmZlY3RzLCBvciBhIGdlbmVyYXRvcj8gOyA8Yj5Gb29kPC9iPiBzYWZldHkgYW5kIGhlYWx0aCA8Yj5wZXJtaXRzPC9iPi4gQXJlIHlvdSZuYnNwOy4uLiI7czo1OiJ0aXRsZSI7czo0MzoiQ29tbW9uIHBlcm1pdHMgZm9yIHNwZWNpYWwgZXZlbnRzIGluIEJvc3RvbiI7fX1zOjQ6ImJvYnkiO3M6OTA5OiIqKkNBQ0hFRCoqIFRvIGdldCBhIGZvb2QgdHJ1Y2sgcGVybWl0IGluIEJvc3RvbiwgeW91J2xsIG5lZWQgc2V2ZXJhbCBkb2N1bWVudHMsIGluY2x1ZGluZyBwZXJtaXRzIGZyb20gSW5zcGVjdGlvbmFsIFNlcnZpY2VzIGFuZCBGaXJlLCBhIEhhd2tlciBhbmQgUGVkZGxlciBMaWNlbnNlLCBhIEJ1c2luZXNzIENlcnRpZmljYXRlLCBhIENlcnRpZmljYXRlIG9mIExpYWJpbGl0eSBJbnN1cmFuY2UsIGEgYnVzaW5lc3MgcGxhbiwgYW5kIGEgd3JpdHRlbiBhZ3JlZW1lbnQgZnJvbSB5b3VyIGNvbW1pc3NhcnkuIFsxXSBZb3UnbGwgYWxzbyBuZWVkIGEgZmlyZSBzdXBwcmVzc2lvbiBzeXN0ZW0gYW5kIGEgZmlyZSBleHRpbmd1aXNoZXIgZm9yIHlvdXIgdHJ1Y2suIFsyXSBPbmNlIHlvdXIgdHJ1Y2sgcGFzc2VzIHRoZSBmaXJlIGluc3BlY3Rpb24sIHRoZSBmaXJlIGluc3BlY3RvciB3aWxsIHNpZ24geW91ciBmb29kIHRydWNrIHBlcm1pdCBhcHBsaWNhdGlvbi4gWzJdICBZb3UnbGwgbmVlZCB0byBnZXQgcGVybWlzc2lvbiBmcm9tIHRoZSBwcm9wZXJ0eSBvd25lciBpZiB5b3UgcGxhbiB0byBvcGVyYXRlIGluIGEgcHJpdmF0ZSBsb2NhdGlvbi4gWzFdICBGaW5hbGx5LCB5b3UnbGwgbmVlZCB0byBzdWJtaXQgeW91ciBmb29kIHRydWNrIHBlcm1pdCBhcHBsaWNhdGlvbiwgYSBsZWFzZSBvciBsZXR0ZXIgb2YgYWdyZWVtZW50IGZyb20gdGhlIHByb3BlcnR5IG93bmVyLCBhIHBob3RvIG9mIHRoZSBzaXRlLCBhIGRyYXdpbmcgc2hvd2luZyB3aGVyZSB5b3VyIHRydWNrIHdpbGwgYmUgbG9jYXRlZCBhbmQgaG93IHlvdSBwbGFuIHRvIHNlcnZlIGZvb2QsIGFuZCBhIGNvcHkgb2YgeW91ciBVc2Ugb2YgUHJlbWlzZXMgcGVybWl0IGFwcHJvdmVkIGJ5IEluc3BlY3Rpb25hbCBTZXJ2aWNlcy4gWzVdIAoiO3M6OToiY2l0YXRpb25zIjthOjU6e2k6MTthOjQ6e3M6NToidGl0bGUiO3M6NDM6IkhvdyB0byBnZXQgYSBmb29kIHRydWNrIHBlcm1pdCB8IEJvc3Rvbi5nb3YiO3M6ODoiZG9jdW1lbnQiO3M6MTYyOiJwcm9qZWN0cy82MTIwNDI2MTI1ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9kcnVwYWx3ZWJzaXRlXzE3MDI5MTkxMTk3NjgvYnJhbmNoZXMvMC9kb2N1bWVudHMvMDk1YWU0MjYwNjc3YjZjZTQ1ZTM1NzM1MGQzMWU2YTkiO3M6MzoidXJpIjtzOjg3OiJodHRwczovL3d3dy5ib3N0b24uZ292L2RlcGFydG1lbnRzL3NtYWxsLWJ1c2luZXNzLWRldmVsb3BtZW50L2hvdy1nZXQtZm9vZC10cnVjay1wZXJtaXQiO3M6OToibG9jYXRpb25zIjthOjI6e2k6MDthOjI6e3M6MTA6InN0YXJ0SW5kZXgiO2k6MDtzOjg6ImVuZEluZGV4IjtzOjM6IjM2MSI7fWk6MTthOjI6e3M6MTA6InN0YXJ0SW5kZXgiO3M6MzoiMzYyIjtzOjg6ImVuZEluZGV4IjtzOjM6IjQ3MCI7fX19aToyO2E6NDp7czo1OiJ0aXRsZSI7czo2ODoiSG93IHRvIGdldCBhIEhlYWx0aCBhbmQgRmlyZSBwZXJtaXQgZm9yIHlvdXIgZm9vZCB0cnVjayB8IEJvc3Rvbi5nb3YiO3M6ODoiZG9jdW1lbnQiO3M6MTYyOiJwcm9qZWN0cy82MTIwNDI2MTI1ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9kcnVwYWx3ZWJzaXRlXzE3MDI5MTkxMTk3NjgvYnJhbmNoZXMvMC9kb2N1bWVudHMvMmExNzcyMTZlMDg1MWIxMGQyN2Y4YzM4YmZmYjVjNTIiO3M6MzoidXJpIjtzOjEwODoiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9zbWFsbC1idXNpbmVzcy1kZXZlbG9wbWVudC9ob3ctZ2V0LWhlYWx0aC1hbmQtZmlyZS1wZXJtaXQteW91ci1mb29kLXRydWNrIjtzOjk6ImxvY2F0aW9ucyI7YTowOnt9fWk6MzthOjQ6e3M6NToidGl0bGUiO3M6MjQ6IkZvb2QgVHJ1Y2tzIHwgQm9zdG9uLmdvdiI7czo4OiJkb2N1bWVudCI7czoxNjI6InByb2plY3RzLzYxMjA0MjYxMjU4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL2RydXBhbHdlYnNpdGVfMTcwMjkxOTExOTc2OC9icmFuY2hlcy8wL2RvY3VtZW50cy9kN2RhZjY4NDQwNDIzZWJkNTgzZWE3MjM1YTUzMjgxYiI7czozOiJ1cmkiO3M6NzM6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvc21hbGwtYnVzaW5lc3MtZGV2ZWxvcG1lbnQvZm9vZC10cnVja3MiO3M6OToibG9jYXRpb25zIjthOjA6e319aTo0O2E6NDp7czo1OiJ0aXRsZSI7czoyNDoiRm9vZCBUcnVja3MgfCBCb3N0b24uZ292IjtzOjg6ImRvY3VtZW50IjtzOjE2MjoicHJvamVjdHMvNjEyMDQyNjEyNTg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2RhdGFTdG9yZXMvZHJ1cGFsd2Vic2l0ZV8xNzAyOTE5MTE5NzY4L2JyYW5jaGVzLzAvZG9jdW1lbnRzL2IxZWE5MmFlOWJmY2Q5MzA5MzA0MDYxMDgxZjUxMmNkIjtzOjM6InVyaSI7czo1NToiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9lY29ub21pYy1kZXZlbG9wbWVudC9mb29kLXRydWNrcyI7czo5OiJsb2NhdGlvbnMiO2E6MTp7aTowO2E6Mjp7czoxMDoic3RhcnRJbmRleCI7czozOiI1NzMiO3M6ODoiZW5kSW5kZXgiO3M6MzoiODc2Ijt9fX1pOjU7YTo0OntzOjU6InRpdGxlIjtzOjI5OiJGb29kIHRydWNrIHNpdGVzIHwgQm9zdG9uLmdvdiI7czo4OiJkb2N1bWVudCI7czoxNjI6InByb2plY3RzLzYxMjA0MjYxMjU4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL2RydXBhbHdlYnNpdGVfMTcwMjkxOTExOTc2OC9icmFuY2hlcy8wL2RvY3VtZW50cy84YzIwNzc5NmZmNWM1MGE1Yzk3MzNlYzdhYzMzNDhlMyI7czozOiJ1cmkiO3M6Nzg6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvc21hbGwtYnVzaW5lc3MtZGV2ZWxvcG1lbnQvZm9vZC10cnVjay1zaXRlcyI7czo5OiJsb2NhdGlvbnMiO2E6MDp7fX19czoxNToiY29udmVyc2F0aW9uX2lkIjtzOjE5OiIzNjQ5NTQwNzQ0NDUyMDcxNjMxIjt9"); + return unserialize($a); + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + return $this->service->availablePrompts(); + } + + /** + * @param array &$elements + * @param array $map + * @param array $exclude_elem + * @param string $prefix * + * +* @inheritDoc + */ + protected function flattenMetadata(array &$metadata, array $map = [], array $exclude_elem = []):array { + + $map = []; + $exclude_elem = []; + return parent::flattenMetadata($metadata, $map, $exclude_elem); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/src/Plugin/AiSearch/GcVertexSearch.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/src/Plugin/AiSearch/GcVertexSearch.php new file mode 100644 index 0000000000..13dc640ab2 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/modules/bos_gc_aisearch_plugin/src/Plugin/AiSearch/GcVertexSearch.php @@ -0,0 +1,524 @@ +get("preset") ?? []; + + if ($fake) { + $response = $this->fakeResponse(); + } + else { + $parameters = [ + "text" => $request->get("search_text") ?? "", + "allow_conversation" => $preset["searchform"]["searchbar"]["allow_conversation"] ?? FALSE, + "session_id" => $request->get("session_id") ?? "", + "prompt" => $preset["prompt"] ?? 'default', + "extra_prompt" => 'If you cannot understand the question or the question cannot be answered, start the response with the text "' . self::NO_RESULTS . '"', + "metadata" => $preset["results"]["metadata"] ?? 0, + "num_results" => $preset["results"]["result_count"] ?? 0, + "include_citations" => $preset["results"]["citations"] ?? 0, +// "min_citation_relevance" => $preset["results"]["min_citation_relevance"] ?? 0, + "related_questions" => $preset["results"]["related_questions"] ?? 0, + "safe_search" => $preset["model_tuning"]['search']["safe_search"] ?? 0, + "ignoreAdversarialQuery" => $preset["model_tuning"]['summary']["ignoreAdversarialQuery"] ?? 0, + "ignoreNonSummarySeekingQuery" => $preset["model_tuning"]['summary']["ignoreNonSummarySeekingQuery"] ?? 0, + "ignoreLowRelevantContent" => $preset["model_tuning"]['summary']["ignoreLowRelevantContent"] ?? 0, + "ignoreJailBreakingQuery" => $preset["model_tuning"]['summary']["ignoreJailBreakingQuery"] ?? 0, + "semantic_chunks" => $preset["model_tuning"]['summary']["semantic_chunks"] ?? 0, + ]; + + // Apply any service overrides. + if (!empty($preset["model_tuning"]["overrides"]["service_account"]) && $preset["model_tuning"]["overrides"]["service_account"] != "default") { + $parameters["service_account"] = $preset["model_tuning"]["overrides"]["service_account"]; + $this->service->setServiceAccount($parameters["service_account"]); + } + if (!empty($preset["model_tuning"]["overrides"]["project_id"]) && $preset["model_tuning"]["overrides"]["project_id"] != "default") { + $parameters["project_id"] = $preset["model_tuning"]["overrides"]["project_id"]; + } + if (!empty($preset["model_tuning"]["overrides"]["datastore_id"]) && $preset["model_tuning"]["overrides"]["datastore_id"] != "default") { + $parameters["datastore_id"] = $preset["model_tuning"]["overrides"]["datastore_id"]; + } + if (!empty($preset["model_tuning"]["overrides"]["engine_id"]) && $preset["model_tuning"]["overrides"]["engine_id"] != "default") { + $parameters["engine_id"] = $preset["model_tuning"]["overrides"]["engine_id"]; + } + + // Query the Agent Builder. + $response = $this->service->execute($parameters); + + } + } + catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + + // Load the SearchResponse object into the AiSearchResponse object. + if ($response) { + $output = $this->loadSearchResponse($this->getService()->response(), $preset, $request); + } + + return $output; + } + + /** + * @inheritDoc + */ + public function hasFollowUp(): bool { + return $this->service->hasFollowup(); + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + return $this->service->availablePrompts(); + } + + /** + * Loads a GoogleCloud SearchResponse object. + * + * Uses a standardized AiSearchResponse object which can be consumed by + * bos_search. + * + * @param array $fullResponse + * @param $preset + * @param \Drupal\bos_search\AiSearchRequest $request + * + * @return AiSearchResponse + * + * @see bos_google_cloud/src/Apis/v1alpha/SearchResponse.php + * @see bos_search/src/AiSearchResponse.php + */ + private function loadSearchResponse(array $fullResponse, $preset, AiSearchRequest $request): AiSearchResponse { + + $searchResponse = $fullResponse["object"]->toArray(); + + $aiSearchResponse = new AiSearchResponse($request, $searchResponse["summary"]["summaryText"], $this->service->getSessionInfo()["session_id"] ?? ""); + + // Load any citations. + if ($preset["results"]["citations"] && !empty($searchResponse["summary"]["summaryWithMetadata"]["citationMetadata"]["citations"])) { + $this->loadCitations($aiSearchResponse, $searchResponse, $preset); + } + + // Load any results. + if ($preset["results"]["searchresults"] && !empty($searchResponse["results"])) { + $this->loadSearchResults($aiSearchResponse, $searchResponse, $preset); + } + + // [optional] Resolve Search Results into a node and check. + if ($preset["results"]["searchresults"] && !empty($searchResponse["results"])) { + $this->postProcessResults($aiSearchResponse); + } + + if (str_starts_with(trim($searchResponse["summary"]["summaryText"]), self::NO_RESULTS)) { + $aiSearchResponse->set("no_results", TRUE); + } + else { + $aiSearchResponse->set("body", $searchResponse["summary"]["summaryText"]); + } + + // Load the metadata from the SearchResponse and extend with preset info. + if ($preset["results"]["metadata"]) { + $metadata = array_merge($fullResponse["metadata"], ["Search Presets" => $preset]); + $metadata = $this->flattenMetadata($metadata); + + // Reformat a bit for display. + foreach ($metadata as &$metadatum) { + foreach ($metadatum as $field => $value) { + $field_parts = explode(".", $field); + if (count($field_parts) > 1) { + $new_field = $field_parts[0]; + $counter = 0; + foreach (array_slice($field_parts, 1) as $part) { + $new_field .= "
" . str_repeat(" ", $counter += 2) . "-$part"; + } + $metadatum[$new_field] = $value; + unset($metadatum[$field]); + } + } + } + + $aiSearchResponse->set("metadata", $metadata); + } + + return $aiSearchResponse; + + } + + /** + * Load the GCSearchResults into AiSearchResponse format for Search Results. + * + * Also mark where results are duplicated in the list of references. + * + * @param AiSearchResponse $aiSearchResponse Search response object. + * @param array $searchResponse Array of values to load. + * @param array $preset The preset for this plugin. + */ + private function loadSearchResults(AiSearchResponse &$aiSearchResponse, array $searchResponse, array $preset):void { + + $references = $aiSearchResponse->getReferences(); + $hasCitations = $preset["results"]["citations"]; + $noDupCitation = $preset["results"]["no_dup_citations"]; + + foreach ($searchResponse["results"] as $search_result) { + $ds = $search_result["document"]["derivedStructData"]; + $title = explode("|", $ds["htmlTitle"], 2)[0]; + $res = new AiSearchResult($title, $ds["link"], $ds["snippets"][0]["snippet"] ?: ""); + $docid = $search_result["id"]; + + $filter = new CustomFiltersExtension(); + $content = $ds["extractive_answers"][0]["content"] ?: FALSE; + if ($filter->hasNonEnglishChars($content) || !$content) { + // If the selected content string contains non-english content, then + // try the alternative extractive output. + $content = $ds["extractive_segments"][0]["content"] ?: FALSE; + if ($filter->hasNonEnglishChars($content) || !$content) { + // Still not english content, so set to empty string and the + // postProcessResults() will inject summary content from the node. + $content = ""; + } + } + + $res->set("id", $docid) + ->set("link_title", explode("|", $ds["title"], 2)[0]) + ->set("ref", $search_result["document"]["name"]) + ->set("content", $content) + ->set("description", "") + ->set("is_citation", FALSE); + + // Check if this result is also in the citations (references) list. + if ($hasCitations) { + foreach ($references as $reference) { + $refdoc = explode("/", $reference["ref"]); + $refdocid = array_pop($refdoc); + if ($docid == $refdocid) { + $res->set("is_citation", TRUE); + break; + } + } + } + + // Actually load this Result. + if ($noDupCitation) { + if (!$res->get("is_citation")) { + // If not loading results which are also citations + // and this result is not also a citation, then load. + $aiSearchResponse->addResult($res); + } + } + else { + // If we are allowing results which are also citations + // then load. + $aiSearchResponse->addResult($res); + } + } + } + + /** + * Load GCSearchResults into AiSearchResponse fmt for Citation & References. + * + * Also mark where references are duplicated in the list of search results. + * + * @param \Drupal\bos_search\Model\AiSearchResponse $aiSearchResponse + * @param array $searchResponse + * @param array $preset + * + * @return void + */ + private function loadCitations(AiSearchResponse &$aiSearchResponse, array $searchResponse, array $preset):void { + + $citations = $searchResponse['summary']['summaryWithMetadata']['citationMetadata']['citations']; + $references = $searchResponse["summary"]["summaryWithMetadata"]["references"]; + + // Cycle through references and deduplicate them. + // Update Citation source when a duplicate is found so ref is not lost. + $refs = []; + foreach ($references as $ref_key => $reference) { + if (array_key_exists($reference["document"], $refs)) { + // Need to deduplicate and update Citation. + $first_instance = $refs[$reference["document"]]; + foreach ($citations as &$citation) { + foreach ($citation["sources"] as &$source) { + if ($source["referenceIndex"] == $ref_key) { + $source["referenceIndex"] = $first_instance; + } + } + } + } + else { + $refs[$reference["document"]] = $ref_key; + } + } + + // Cycle through the Citations, and load them into aiSearchResponse. + foreach ($citations as $citation_key => $citation) { + + $searchCitation = new AiSearchCitation($citation['startIndex'], $citation['endIndex']); + + // Get find the relevance score for each source (Reference) and only + // save the source if it is the only one, or if it is above the threshold + // set in the preset. + foreach ($citation['sources'] as $cit_source_key => $source) { + $sourceReference = $references[$source["referenceIndex"]]; + if (count($citation['sources']) == 1 + || $sourceReference["extraInfo"]["relevanceScore"] >= $preset["results"]["min_citation_relevance"]) { + $source["relevanceScore"] = $sourceReference["extraInfo"]["relevanceScore"]; + $searchCitation->addSource($source, $cit_source_key); + } + } + $aiSearchResponse->addCitation($searchCitation, $citation_key); + + } + + // Now reload only the citations that are loaded into aiSearchResponse, + // and update the referenceIndex with the new ID's. + $citations = $aiSearchResponse->getCitations(); + + // Cycle through the References and load them into aiSearchResponse. + $idx = 0; + foreach ($references as $reference_key => $reference) { + + $title = explode("|", $reference["title"], 2)[0]; + $searchReference = new AiSearchReference($title, $reference["uri"], $reference["document"]); + $searchReference->addChunkContent($reference["chunkContents"]["content"], $reference["chunkContents"]["pageIdentifier"] ?? ""); + $doc = explode("/", $reference["document"]); + $searchReference->set("id", array_pop($doc)); + $searchReference->set("relevanceScore", $reference["extraInfo"]["relevanceScore"]); + + // Find Citations which use this Reference and add in the location (char + // range) for the Summary Annotation. + foreach ($citations as $citation) { + $locations = []; + foreach ($citation["sources"] as $source) { + if ($source["referenceIndex"] == $reference_key) { + $locations[] = [ + "startIndex" => $citation["startIndex"] ?? 0, + "endIndex" => $citation["endIndex"] ?? strlen($aiSearchResponse["body"]), + ]; + break; + } + } + $searchReference->set("locations", $locations); + } + + // Set a flag if this Reference is used in any SearchResults. + $searchReference->set("is_result", FALSE); + foreach ($searchResponse["results"] as $result) { + if ($result["id"] == $searchReference->get("id")) { + $searchReference->set("is_result", TRUE); + break; + } + } + + // Only load this Reference if it has a location in the Citation. + // Some references are returned which do not have citations, presumably + // because they were used in drafts, or the citation limit means the + // Citation did not appear in the final listing returned by the API. + if (count($searchReference->get("locations"))) { + $idx++; + $searchReference->set("original_seq", $reference_key); + $searchReference->set("seq", $idx); + $aiSearchResponse->addReference($searchReference, $idx); + + // Update the Citations with the newly set referenceIndex ($idx). + $citation_collection = $aiSearchResponse->getCitationsCollection(); + foreach ($citation_collection->getCitations() as $cit_key => $citation) { + foreach ($citation["sources"] as &$source) { + if ($source["referenceIndex"] == $reference_key) { + // Use a negative number so we don't end up with this being + // overwritten on another pass though the loop. + $source["referenceIndex"] = -$idx; + } + } + $citation_collection->updateCitation($cit_key, $citation); + } + + } + + } + // Remove any negative ReferenceIndexes created above. + foreach ($citation_collection->getCitations() as $cit_key => $citation) { + foreach ($citation["sources"] as &$source) { + if ($source["referenceIndex"] < 0) { + $source["referenceIndex"] = abs($source["referenceIndex"]); + } + } + $citation_collection->updateCitation($cit_key, $citation); + } + + // Make sure the Citations are indexed correctly. + $references = $aiSearchResponse->getReferences(); + $citations = $aiSearchResponse->getCitations(); + + // Add Annotations to the summary Text, for Citations and References + // that remain. Copy the original summary to "body" and save the annotated + // summary. + $summary = $searchResponse["summary"]["summaryWithMetadata"]["summary"]; + $aiSearchResponse->set("body", $summary); + + foreach ($citations as $citation) { + $text = substr($summary, $citation["startIndex"], ($citation["endIndex"] - $citation["startIndex"])); + $citation_collection = []; + // Check the sources, de-duplicating them using the referenceIndex. + foreach ($citation["sources"] as $cit_source) { + $citation_collection[$cit_source["referenceIndex"]] = $cit_source["referenceIndex"]; + } + $citation_collection = implode(",", array_keys($citation_collection)); + $summaryParts[] = trim($text) . "[$citation_collection] "; + } + $summary = implode("", $summaryParts); + $aiSearchResponse->set("summary", $summary); + + } + + /** + * Post-processes the search results to enhance content. + * + * - Finding the nid for the node. + * - Checking language of page. + * - Loading the Drupal summary for content. + * + * @param AiSearchResponse $aiSearchResponse + * The response object containing the initial search results. + * + * @return void + * This method does not return a value but modifies the results directly. + */ + private function postProcessResults(AiSearchResponse $aiSearchResponse):void { + + $results = $aiSearchResponse->getResultsCollection(); + + $alias_manager = \Drupal::service('path_alias.manager'); + $redirect_manager = \Drupal::service('redirect.repository'); + + foreach ($results->getResults() as $key => $result) { + + // The content field may be empty if either: the AI did not return an + // extractive_answer or an extractive_segment (unlikely) or if both have + // non-english chars in them. If content is empty, then find the node and + // extract a summary from the body of the content. + if (empty($result->get('content'))) { + + $path_alias = explode(".gov", $result->get("link"), 2)[1]; + + if (!empty($path_alias)) { + // Strip out the alias from any other querystings etc. + $path_alias = explode('?', $path_alias, 2); + $path_alias = explode('#', $path_alias[0], 2)[0]; + + // Get the nid for this page alias (to prevent duplicates). + $path = $alias_manager->getPathByAlias($path_alias); + $path_parts = explode('/', $path); + $nid = array_pop($path_parts); + + if (!is_numeric($nid)) { + // If we can't get the node ID then it is possibly a redirect to + // another page, so try to track that down... + $nid = NULL; + $redirects = $redirect_manager->findBySourcePath(trim($path_alias, "/")); + if (!empty($redirects)) { + $redirect = reset($redirects); + $original_alias = explode(":", $redirect->getRedirect()['uri'], 2)[1] ?? $redirect->getRedirect()['uri']; + $path = $alias_manager->getPathByAlias($original_alias); + $path_parts = explode('/', $path); + $nid = array_pop($path_parts); + } + } + + if ($nid) { + $node = \Drupal::entityTypeManager() + ->getStorage('node') + ->load($nid); + + $content = ""; + // Build up a summary. + if ($node && $node->hasField("field_intro_text")) { + $content .= $node->get("field_intro_text")->value; + } + if ($node && $node->hasField("body")) { + $content .= ($node->get("body")->summary ?: $node->get("body")->value); + } + if ($node && $node->hasField("field_need_to_know")) { + $content .= $node->get("field_need_to_know")->value; + } + + // Update the result. + $result->set("nid", $nid); + $result->set("content", AiSearch::sanitize(strip_tags($content))); + $results->updateResult($key, $result); + + } + } + } + } + + } + + /** + * @param array &$elements + * @param array $map + * @param array $exclude_elem + * @param string $prefix * + * +* @inheritDoc + */ + protected function flattenMetadata(array &$metadata, array $map = [], array $exclude_elem = []): array { + $map = []; + $exclude_elem = [ + "headers.Authorization", + "answer_response_raw", + "response_raw", + ]; + return parent::flattenMetadata($metadata, $map, $exclude_elem); + } + + /** + * Generates a fake response for the search functionality. + * + * This method is used primarily for testing and development + * purposes. It simulates a response that would come from the + * search service, allowing developers to test the flow and + * interaction without requiring a live service connection. + * + * @return AiSearchResponse A simulated search response. + */ + private function fakeResponse() { + $a = base64_decode("Tzo1MToiRHJ1cGFsXGJvc19nb29nbGVfY2xvdWRcQXBpc1x2MWFscGhhXFNlYXJjaFJlc3BvbnNlIjoxOntzOjk6IgAqAG9iamVjdCI7YTo3OntzOjc6InJlc3VsdHMiO2E6NTp7aTowO2E6Mjp7czoyOiJpZCI7czozMjoiYmQ3ZmI0ZTlhNTQ0ZDgyNWRkNmJiNWE0ODIyMGFkYWYiO3M6ODoiZG9jdW1lbnQiO2E6Mzp7czo0OiJuYW1lIjtzOjE2OToicHJvamVjdHMvNzM4MzEzMTcyNzg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2RhdGFTdG9yZXMvb2VvaS1waWxvdC1kYXRhc3RvcmVfMTcyNjI2NTc5NTkxMC9icmFuY2hlcy8wL2RvY3VtZW50cy9iZDdmYjRlOWE1NDRkODI1ZGQ2YmI1YTQ4MjIwYWRhZiI7czoyOiJpZCI7czozMjoiYmQ3ZmI0ZTlhNTQ0ZDgyNWRkNmJiNWE0ODIyMGFkYWYiO3M6MTc6ImRlcml2ZWRTdHJ1Y3REYXRhIjthOjc6e3M6ODoic25pcHBldHMiO2E6MTp7aTowO2E6Mjp7czo3OiJzbmlwcGV0IjtzOjEwMjoiTGVhcm4gbW9yZSBhYm91dCBob3cgdG8gYXBwbHkgZm9yIGNlcnRpZmljYXRpb24gd2l0aCB0aGUgPGI+Q2l0eSYjMzk7cyBTdXBwbGllcjwvYj4gRGl2ZXJzaXR5IFByb2dyYW0uIjtzOjE0OiJzbmlwcGV0X3N0YXR1cyI7czo3OiJTVUNDRVNTIjt9fXM6MTk6ImV4dHJhY3RpdmVfc2VnbWVudHMiO2E6MTp7aTowO2E6MTp7czo3OiJjb250ZW50IjtzOjEwMTE6IllvdXIgdmVuZG9yIGFjY291bnQgd2lsbCBhbGxvdyB5b3UgdG8gc2VlIGFuZCBiaWQgb24gQ2l0eSBjb250cmFjdHMuIEtlZXAgaW4gbWluZCBZb3UgY2FuIHNlYXJjaCBvdXIgZGF0YWJhc2UgdG8gZmluZCBjZXJ0aWZpZWQgZGl2ZXJzZSBhbmQgc21hbGwgYnVzaW5lc3NlcyBpbiBCb3N0b24uIFdlIGFsc28gaGF2ZSBhIGxpc3Qgb2YgYWxsIG9wZW4gYmlkIHByb2plY3RzIGluIHRoZSBDaXR5IG9mIEJvc3Rvbi4gWW91IGNhbiBnZXQgYSBwYWlkIG1haWwgc3Vic2NyaXB0aW9uLCBvciBzZWUgYSBsaXN0IG9mIGN1cnJlbnQgYmlkcyBvbmxpbmUuIFJlbGF0ZWQgUmVzb3VyY2VzIFJlbGF0ZWQgUmVzb3VyY2VzIEhvdyB0byBhcHBseSBmb3IgYSBDaXR5IG9mIEJvc3RvbiBidXNpbmVzcyBjZXJ0aWZpY2F0ZSBTaWduIHVwIGZvciBvdXIgbmV3c2xldHRlciBDb250YWN0OiBTdXBwbGllciBEaXZlcnNpdHkgU2lnbiB1cCBmb3Igb3VyIFN1cHBsaWVyIERpdmVyc2l0eSBuZXdzbGV0dGVyIHRvIGxlYXJuIGFib3V0IHVwY29taW5nIENpdHkgY29udHJhY3Rpbmcgb3Bwb3J0dW5pdGllcywgZXZlbnRzLCBhbmQgd29ya3Nob3BzLiBZb3VyIEVtYWlsIEFkZHJlc3MgWmlwIENvZGUgR290Y2hhIFNpZ24gVXAgSGF2ZSBxdWVzdGlvbnM/IENvbnRhY3Q6IHN1cHBsaWVyIGRpdmVyc2l0eSBwcm9ncmFtIDYxNy02MzUtNDUxMSBidXNpbmVzc2NlcnRpZmljYXRpb25AYm9zdG9uLmdvdiBaT09NIENFUlRJRklDQVRJT04gSE9VUlMgSm9pbiBvdXIgd2Vla2x5IE1XQkUgWm9vbSBDZXJ0aWZpY2F0aW9uIEhvdXJzLCBldmVyeSBXZWRuZXNkYXkgZnJvbSAxMSBhbSAtIDEgcG06IGpvaW4gY2VydGlmaWNhdGlvbiBvZmZpY2UgSG91cnMgUHJvdmlkZSBZb3VyIEZlZWRiYWNrIEJhY2sgdG8gdG9wIEZvb3RlciBtZW51IFByaXZhY3kgUG9saWN5IENvbnRhY3QgdXMgSm9icyBQdWJsaWMgcmVjb3JkcyBMYW5ndWFnZSBhbmQgRGlzYWJpbGl0eSBBY2Nlc3MgQk9TOjMxMSAtIFJlcG9ydCBhbiBpc3N1ZSI7fX1zOjExOiJkaXNwbGF5TGluayI7czoxNDoid3d3LmJvc3Rvbi5nb3YiO3M6NToidGl0bGUiO3M6NDA6IkdldCBZb3VyIEJ1c2luZXNzIENlcnRpZmllZCB8IEJvc3Rvbi5nb3YiO3M6NDoibGluayI7czo5NToiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9zdXBwbGllci1hbmQtd29ya2ZvcmNlLWRpdmVyc2l0eS9nZXQteW91ci1idXNpbmVzcy1jZXJ0aWZpZWQiO3M6MTg6ImV4dHJhY3RpdmVfYW5zd2VycyI7YToxOntpOjA7YToxOntzOjc6ImNvbnRlbnQiO3M6MjYxOiJQcm9vZiBvZiB5b3VyIGJ1c2luZXNzJiMzOTsgcmVnaXN0cmF0aW9uIG1pZ2h0IGluY2x1ZGUgYXJ0aWNsZXMgb2YgaW5jb3Jwb3JhdGlvbiAoY29ycG9yYXRpb24pLCBjZXJ0aWZpY2F0ZSBvZiBvcmdhbml6YXRpb24gKExMQyksIG9yIGEgYnVzaW5lc3MgY2VydGlmaWNhdGUsIHdoaWNoIGNhbiBiZSBvYnRhaW5lZCB0aHJvdWdoIHRoZSBCb3N0b24gQ2l0eSBDbGVyayYjMzk7cyBPZmZpY2UgaWYgeW91ciBidXNpbmVzcyBpcyBsb2NhdGVkIGluIEJvc3Rvbi4iO319czo5OiJodG1sVGl0bGUiO3M6NDA6IkdldCBZb3VyIEJ1c2luZXNzIENlcnRpZmllZCB8IEJvc3Rvbi5nb3YiO319fWk6MTthOjI6e3M6MjoiaWQiO3M6MzI6Ijg5MDA3N2EwNjEyZjVkY2U3NDEyZWQ0YmY3OWI0MjcwIjtzOjg6ImRvY3VtZW50IjthOjM6e3M6NDoibmFtZSI7czoxNjk6InByb2plY3RzLzczODMxMzE3Mjc4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL29lb2ktcGlsb3QtZGF0YXN0b3JlXzE3MjYyNjU3OTU5MTAvYnJhbmNoZXMvMC9kb2N1bWVudHMvODkwMDc3YTA2MTJmNWRjZTc0MTJlZDRiZjc5YjQyNzAiO3M6MjoiaWQiO3M6MzI6Ijg5MDA3N2EwNjEyZjVkY2U3NDEyZWQ0YmY3OWI0MjcwIjtzOjE3OiJkZXJpdmVkU3RydWN0RGF0YSI7YTo3OntzOjk6Imh0bWxUaXRsZSI7czo0NzoiU3RyZWV0IFZlbmRpbmcgR2VuZXJhbCBJbmZvcm1hdGlvbiB8IEJvc3Rvbi5nb3YiO3M6NToidGl0bGUiO3M6NDc6IlN0cmVldCBWZW5kaW5nIEdlbmVyYWwgSW5mb3JtYXRpb24gfCBCb3N0b24uZ292IjtzOjExOiJkaXNwbGF5TGluayI7czoxNDoid3d3LmJvc3Rvbi5nb3YiO3M6MTk6ImV4dHJhY3RpdmVfc2VnbWVudHMiO2E6MTp7aTowO2E6MTp7czo3OiJjb250ZW50IjtzOjE4MzA6IkNpdHkgb2YgQm9zdG9uIE1haW4gbWVudSBIZWxwIC8gMzExIEhvbWUgR3VpZGVzIHRvIEJvc3RvbiBEZXBhcnRtZW50cyBQdWJsaWMgTm90aWNlcyBQYXkgYW5kIGFwcGx5IEpvYnMgYW5kIGNhcmVlcnMgQnVzaW5lc3MgU3VwcG9ydCBFdmVudHMgTmV3cyBQbGFjZXMgQmFjayBDZW1ldGVyaWVzIENvbW11bml0eSBjZW50ZXJzIEhpc3RvcmljIERpc3RyaWN0cyBMaWJyYXJpZXMgTmVpZ2hib3Job29kcyBQYXJrcyBhbmQgcGxheWdyb3VuZHMgU2Nob29scyBHb3Zlcm5tZW50IEJhY2sgVGhlIE1heW9yJ3MgT2ZmaWNlIENpdHkgQ2xlcmsgQ2l0eSBDb3VuY2lsIEVsZWN0aW9ucyBCb2FyZHMgYW5kIGNvbW1pc3Npb25zIENpdHkgZ292ZXJubWVudCBvdmVydmlldyBGZWVkYmFjayBUb2dnbGUgTWVudSBCb3N0b24uZ292IE1heW9yIE1pY2hlbGxlIFd1IENpdHkgb2YgQm9zdG9uIFNlYWwgSW5mb3JtYXRpb24gYW5kIFNlcnZpY2VzIFB1YmxpYyBub3RpY2VzIEZlZWRiYWNrIEVuZ2xpc2ggRXNwYcOxb2wgU29vbWFhbGkgUG9ydHVndcOqcyBmcmFuw6dhaXMg566A5L2T5Lit5paHIFZpZXcgRGlzY2xhaW1lciBFc3Bhw7FvbCBLcmV5w7JsIGF5aXN5ZW4gUG9ydHVndcOqcyBmcmFuw6dhaXMg566A5L2T5Lit5paHIFRp4bq/bmcgVmnhu4d0INCg0YPRgdGB0LrQuNC5IFNvb21hYWxpINin2YTYudix2KjZitipIEFmcmlrYWFucyBzaHFpcCDhiqDhiJvhiK3hipsg2KfZhNi52LHYqNmK2Kkg1bDVodW11aXWgNWl1bYg2KLYsNix2KjYp9uM2KzYp9mGINiv24zZhCBFdXNrYXJhINCR0LXQu9Cw0YDRg9GB0LrQsNGPINC80L7QstCwIOCmrOCmvuCmguCmsuCmviDYqNuJ2LPYp9mG2LPZgtmJINCx0YrQu9Cz0LDRgNGB0LrQuCBjYXRhbMOgIEJpbmlzYXlhIENoaWNoZcW1YSDlub/kuJzor50g5buj5p2x6KmxIENvcnN1IEhydmF0c2tpIMSNZcWhdGluYSBkYW5zayBOZWRlcmxhbmRzIEVzcGVyYW50byBlZXN0aSBrZWVsIFBpbGlwaW5vIHN1b21pIGZyYW7Dp2FpcyDFjHN0ZnLDpGlzayBnYWxlZ28g4YOl4YOQ4YOg4YOX4YOj4YOa4YOYIOGDlOGDnOGDkCBEZXV0c2NoIM6VzrvOu863zr3Ouc66zqwg4KqX4KuB4Kqc4Kqw4Kq+4Kqk4KuAIEtyZXnDsmwgYXlpc3llbiDZh9mO2LHZkti02Y7ZhiDZh9mO2YjZktiz2Y4gyrvFjGxlbG8gSGF3YWnKu2kg16LWtNeR16jWtNeZ16og4KS54KS/4KSC4KSm4KWAIEx1cyBIbW9vYiBNYWd5YXIgw61zbGVuc2thIMOBc+G7pcyAc+G7pcyAIMOMZ2LDsiBiYWhhc2EgSW5kb25lc2lhIEdhZWlsZ2UgSXRhbGlhbm8g5pel5pys6KqeINio2KfYs9inINis2KfZiNinIOCyleCyqOCzjeCyqOCyoSDSmtCw0LfQsNKbINGC0ZbQu9GWIOGel+GetuGen+GetuGegeGfkuGemOGfguGemiDtlZzqta3snbgg2qnZiNix2YXYp9mG2KzbjCDQmtGL0YDQs9GL0Lcg0YLQuNC70Lgg4Lql4Lqy4LqnIExpbmd1YSBMYXRpbmEgbGF0dmllxaF1IHZhbG9kYSBsaWV0dXZpxbMga2FsYmEgTMOrdHplYnVlcmdlc2NoINC80LDQutC10LTQvtC90YHQutC4IG1hbGFnYXN5INio2YfYp9izINmF2YTYp9mK2Ygg4LSu4LSy4LSv4LS+4LSz4LSCIE1hbHRpIE3EgW9yaSDgpK7gpLDgpL7gpKDgpYAg0LzQvtC90LPQvtC7IOGAmeGAvOGAlOGAuuGAmeGArOGAheGAgOGArOGAuCDgpKjgpYfgpKrgpL7gpLLgpYAgbm9yc2sg2b7amtiq2Ygg2YHYp9ix2LPbjCBQb2xza2llIFBvcnR1Z3XDqnMg4Kiq4Kmw4Kic4Ki+4Kis4KmAIGxpbWJhIHJvbcOibsSDINCg0YPRgdGB0LrQuNC5IEdhZ2FuYSBmYSdhIFPEgW1vYSBHw6BpZGhsaWcg0KHRgNC/0YHQutC4IFNvdGhvIGNoaVNob25hINiz2YbajNmKIOC3g+C3kuC2guC3hOC2vSBzbG92ZW7EjWluYSI7fX1zOjg6InNuaXBwZXRzIjthOjE6e2k6MDthOjI6e3M6MTQ6InNuaXBwZXRfc3RhdHVzIjtzOjc6IlNVQ0NFU1MiO3M6Nzoic25pcHBldCI7czoxODI6Ikhhd2tlcnMgYW5kIFBlZGRsZXJzIExpY2Vuc2UgQW55IDxiPnZlbmRvcjwvYj4gc2VsbGluZyBtZXJjaGFuZGlzZSBpbiA8Yj5Cb3N0b248L2I+IGlzIHJlcXVpcmVkIHRvIGhhdmUgYSBIYXdrZXJzIGFuZCBQZWRkbGVycyBMaWNlbnNlLiBZb3UgY2FuIDxiPmdldDwvYj4gdGhpcyBsaWNlbnNlIGZyb206Jm5ic3A7Li4uIjt9fXM6MTg6ImV4dHJhY3RpdmVfYW5zd2VycyI7YToxOntpOjA7YToxOntzOjc6ImNvbnRlbnQiO3M6ODIxOiJIYXdrZXJzIGFuZCBQZWRkbGVycyBMaWNlbnNlIEFueSB2ZW5kb3Igc2VsbGluZyBtZXJjaGFuZGlzZSBpbiBCb3N0b24gaXMgcmVxdWlyZWQgdG8gaGF2ZSBhIEhhd2tlcnMgYW5kIFBlZGRsZXJzIExpY2Vuc2UuIFlvdSBjYW4gZ2V0IHRoaXMgbGljZW5zZSBmcm9tOiBEaXZpc2lvbiBvZiBQcm9mZXNzaW9uYWwgTGljZW5zdXJlIE9uZSBBc2hidXJ0b24gUGxhY2UgQm9zdG9uLCBNQSAwMjEwOCBIYXdrZXIgYW5kIFBlZGRsZXIgTGljZW5zZSBJbmZvcm1hdGlvbiBTdGF0aW9uYXJ5IFZlbmRpbmcgTGljZW5zZSBWZW5kb3JzIGludGVuZGluZyB0byBzZWxsIGdvb2RzIG9uIGEgcHVibGljIHNpZGV3YWxrIG9yIHByb3BlcnR5IG11c3QgZ2V0IGEgcGVybWl0IGZyb206IERlcGFydG1lbnQgb2YgUHVibGljIFdvcmtzIChEUFcpIDEgQ2l0eSBIYWxsIFBsYXphLCBSb29tIDcxNCBCb3N0b24sIE1BIDAyMjAxIFN0YXRpb25hcnkgVmVuZGluZyBBcHBsaWNhdGlvbiBVc2Ugb2YgUHJlbWlzZXMgUGVybWl0IFNlbGxpbmcgZ29vZHMgb24gcHJpdmF0ZSBwcm9wZXJ0eSB3aWxsIHJlcXVpcmUgYSBVc2Ugb2YgUHJlbWlzZXMgUGVybWl0IGZyb206IEluc3BlY3Rpb25hbCBTZXJ2aWNlcyBEZXBhcnRtZW50IDEwMTAgTWFzc2FjaHVzZXR0cyBBdmUuIEJvc3RvbiwgTUEgMDIxMTggSW5zcGVjdGlvbmFsIFNlcnZpY2VzIERlcGFydG1lbnQgUGVybWl0cyBNYXNzYWNodXNldHRzIFN0YXRlIFNhbml0YXJ5IENvZGUgQXBwbGljYW50cyBtdXN0IGdldCBhIGNvcHkgb2YgdGhlIE1hc3NhY2h1c2V0dHMgU3RhdGUgU2FuaXRhcnkgQ29kZSAxMDVDTVI1OTAuMDAwLiI7fX1zOjQ6ImxpbmsiO3M6OTE6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvaW5zcGVjdGlvbmFsLXNlcnZpY2VzL3N0cmVldC12ZW5kaW5nLWdlbmVyYWwtaW5mb3JtYXRpb24iO319fWk6MjthOjI6e3M6MjoiaWQiO3M6MzI6ImNhYjNiZDE0ZGZjYzBhYmE4Y2FlMjBmZTFkNzg0MGU3IjtzOjg6ImRvY3VtZW50IjthOjM6e3M6NDoibmFtZSI7czoxNjk6InByb2plY3RzLzczODMxMzE3Mjc4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL29lb2ktcGlsb3QtZGF0YXN0b3JlXzE3MjYyNjU3OTU5MTAvYnJhbmNoZXMvMC9kb2N1bWVudHMvY2FiM2JkMTRkZmNjMGFiYThjYWUyMGZlMWQ3ODQwZTciO3M6MjoiaWQiO3M6MzI6ImNhYjNiZDE0ZGZjYzBhYmE4Y2FlMjBmZTFkNzg0MGU3IjtzOjE3OiJkZXJpdmVkU3RydWN0RGF0YSI7YTo3OntzOjU6InRpdGxlIjtzOjUyOiJIb3cgVG8gQXBwbHkgRm9yIEEgQnVzaW5lc3MgQ2VydGlmaWNhdGUgfCBCb3N0b24uZ292IjtzOjQ6ImxpbmsiO3M6NzY6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvY2l0eS1jbGVyay9ob3ctYXBwbHktYnVzaW5lc3MtY2VydGlmaWNhdGUiO3M6OToiaHRtbFRpdGxlIjtzOjUyOiJIb3cgVG8gQXBwbHkgRm9yIEEgQnVzaW5lc3MgQ2VydGlmaWNhdGUgfCBCb3N0b24uZ292IjtzOjExOiJkaXNwbGF5TGluayI7czoxNDoid3d3LmJvc3Rvbi5nb3YiO3M6MTk6ImV4dHJhY3RpdmVfc2VnbWVudHMiO2E6MTp7aTowO2E6MTp7czo3OiJjb250ZW50IjtzOjkwNToiUGxlYXNlIG5vdGU6IGlmIHlvdXIgZGViaXQgY2FyZCByZXF1aXJlcyB5b3UgdG8gZW50ZXIgeW91ciBwaW4gdG8gcHJvY2VzcyBhIHBheW1lbnQsIHlvdSBDQU5OT1QgdXNlIGl0IHRvIHBheSB5b3VyIGZlZS4gQXBwbHlpbmcgYnkgbWFpbD8gSWYgeW91IHNlbmQgeW91ciBwYXltZW50IGJ5IG1haWwsIHBsZWFzZSBpbmNsdWRlIGEgY2hlY2sgb3IgbW9uZXkgb3JkZXIgbWFkZSBwYXlhYmxlIHRvIHRoZSBDaXR5IG9mIEJvc3Rvbi4gU3RlcCAyIE1ha2Ugc3VyZSB5b3UgaGF2ZSBhbGwgeW91ciBpbmZvcm1hdGlvbiBGb3Igc29tZSBidXNpbmVzc2VzLCB3ZSByZXF1aXJlIG90aGVyIGRvY3VtZW50cy4gVG8gZmlsZSBhIGJ1c2luZXNzIHJlZ2lzdHJhdGlvbiBhcyBhIGZvb2QgdHJ1Y2sgdmVuZG9yLCB5b3UgbXVzdCBoYXZlIGEgdmFsaWQ6IGhlYWx0aCBwZXJtaXQgZmlyZSBwZXJtaXQgSGF3a2VycyBhbmQgUGVkZGxlcnMgTGljZW5zZSBjb21taXNzYXJ5IGtpdGNoZW4gYWdyZWVtZW50IG9yIGxldHRlciwgYW5kIENlcnRpZmljYXRlIG9mIExpYWJpbGl0eSBJbnN1cmFuY2UuIFRvIGZpbGUgYSBidXNpbmVzcyByZWdpc3RyYXRpb24gZm9yIHNob3J0LXRlcm0gcmVudGFsIGhvdXNpbmcsIHlvdSBtdXN0IGhhdmUgYSByZWdpc3RyYXRpb24gbnVtYmVyIGZyb20gSW5zcGVjdGlvbmFsIFNlcnZpY2VzLiBZb3UnbGwgbmVlZCB0byBnaXZlIHVzIGEgY29weSBvZiB0aGUgcmVnaXN0cmF0aW9uIG51bWJlciBmb3JtLiBZb3UgY2FuIGxlYXJuIG1vcmUgYWJvdXQgc2hvcnQtdGVybSByZW50YWxzIG9ubGluZS4gSWYgeW91IHBsYW4gdG8gb3BlbiBhIGRheWNhcmUgYnVzaW5lc3MsIHlvdSBtdXN0IGdpdmUgdXMgYSBjb3B5IG9mIGEgc3RhdGUtaXNzdWVkIGRheWNhcmUgcHJvdmlkZXIgbGljZW5zZS4iO319czo4OiJzbmlwcGV0cyI7YToxOntpOjA7YToyOntzOjE0OiJzbmlwcGV0X3N0YXR1cyI7czo3OiJTVUNDRVNTIjtzOjc6InNuaXBwZXQiO3M6MTkxOiI8Yj5Cb3N0b248L2I+IGJ1c2luZXNzZXMgbmVlZCB0byA8Yj5nZXQ8L2I+IGEgY2VydGlmaWNhdGUgdGhyb3VnaCB0aGUgPGI+Q2l0eTwvYj4gQ2xlcmsmIzM5O3Mgb2ZmaWNlIC4uLiBUbyBmaWxlIGEgYnVzaW5lc3MgcmVnaXN0cmF0aW9uIGFzIGEgZm9vZCB0cnVjayA8Yj52ZW5kb3I8L2I+LCB5b3UgbXVzdCBoYXZlIGEgdmFsaWQ6LiI7fX1zOjE4OiJleHRyYWN0aXZlX2Fuc3dlcnMiO2E6MTp7aTowO2E6MTp7czo3OiJjb250ZW50IjtzOjM1NzoiTWFpbCB5b3VyIGRvY3VtZW50cywgcGF5bWVudCwgYW5kIGNvbXBsZXRlZCBmb3JtIHRvOiBPZmZpY2Ugb2YgdGhlIENpdHkgQ2xlcmsgMSBDaXR5IEhhbGwgU3F1YXJlLCBSb29tIDYwMSBCb3N0b24sIE1BIDAyMjAxIFVuaXRlZCBTdGF0ZXMgc2hvdyBoaWRlIFJlbmV3IHlvdXIgY2VydGlmaWNhdGUgU3RlcCAxIFByZXBhcmUgeW91ciByZW5ld2FsIGFwcGxpY2F0aW9uIFlvdSBuZWVkIHRvIGdpdmUgdXMgdGhlIG5hbWUgYW5kIGFkZHJlc3Mgb2YgeW91ciBidXNpbmVzcywgYWxvbmcgd2l0aCB0aGUgbmFtZXMgYW5kIGFkZHJlc3NlcyBvZiBhbnkgcGVvcGxlIHdobyBoYXZlIGFuIGludGVyZXN0IGluIHlvdXIgYnVzaW5lc3MuIjt9fX19fWk6MzthOjI6e3M6MjoiaWQiO3M6MzI6IjBmYTg1OWVhYmM0MTFlN2Y1M2Y1ZWIzMzBmZWQyNGVlIjtzOjg6ImRvY3VtZW50IjthOjM6e3M6NDoibmFtZSI7czoxNjk6InByb2plY3RzLzczODMxMzE3Mjc4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL29lb2ktcGlsb3QtZGF0YXN0b3JlXzE3MjYyNjU3OTU5MTAvYnJhbmNoZXMvMC9kb2N1bWVudHMvMGZhODU5ZWFiYzQxMWU3ZjUzZjVlYjMzMGZlZDI0ZWUiO3M6MjoiaWQiO3M6MzI6IjBmYTg1OWVhYmM0MTFlN2Y1M2Y1ZWIzMzBmZWQyNGVlIjtzOjE3OiJkZXJpdmVkU3RydWN0RGF0YSI7YTo3OntzOjE4OiJleHRyYWN0aXZlX2Fuc3dlcnMiO2E6MTp7aTowO2E6MTp7czo3OiJjb250ZW50IjtzOjI3OToiSWYgeW91IHF1ZXN0aW9ucywgeW91IGNhbiBjb250YWN0IHRoZSBkaXZpc2lvbiBhdCA2MTctNjM1LTUzMDAuIFN0ZXAgMiBDb21wbGV0ZSB0aGUgYXBwbGljYXRpb24gTmV3IHZlbmRvcnMgYW5kIHJldHVybmluZyB2ZW5kb3JzIHNob3VsZCBmaWxsIG91dCBvdXIgZmFybWVycyBtYXJrZXQgdmVuZG9yIHByb2ZpbGUgZm9ybS4gWW91IHdpbGwgbmVlZCB0byBpbmNsdWRlIGFueSByZXF1aXJlZCBkb2N1bWVudHMgd2UgYXNrIGZvciBpbiB0aGUgZm9ybSB3aXRoIHlvdXIgYXBwbGljYXRpb24uIjt9fXM6NToidGl0bGUiO3M6NDk6IkhvdyBUbyBUYWtlIFBhcnQgSW4gQSBGYXJtZXJzIE1hcmtldCB8IEJvc3Rvbi5nb3YiO3M6MTE6ImRpc3BsYXlMaW5rIjtzOjE0OiJ3d3cuYm9zdG9uLmdvdiI7czo4OiJzbmlwcGV0cyI7YToxOntpOjA7YToyOntzOjE0OiJzbmlwcGV0X3N0YXR1cyI7czo3OiJTVUNDRVNTIjtzOjc6InNuaXBwZXQiO3M6MjA1OiI8Yj5Cb3N0b248L2I+LmdvdiBBbiBvZmZpY2lhbCB3ZWJzaXRlIG9mIHRoZSA8Yj5DaXR5IG9mIEJvc3RvbjwvYj4uIC4uLiBhcHBseSBmb3IgYSA8Yj52ZW5kb3I8L2I+IHBlcm1pdC4gWW91IGNhbiBhcHBseSBhcyBhIG5ldyAuLi4gTmV3IDxiPnZlbmRvcnM8L2I+IGFuZCByZXR1cm5pbmcgPGI+dmVuZG9yczwvYj4gc2hvdWxkIGZpbGwgb3V0Jm5ic3A7Li4uIjt9fXM6OToiaHRtbFRpdGxlIjtzOjQ5OiJIb3cgVG8gVGFrZSBQYXJ0IEluIEEgRmFybWVycyBNYXJrZXQgfCBCb3N0b24uZ292IjtzOjQ6ImxpbmsiO3M6NzU6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvZm9vZC1hY2Nlc3MvaG93LXRha2UtcGFydC1mYXJtZXJzLW1hcmtldCI7czoxOToiZXh0cmFjdGl2ZV9zZWdtZW50cyI7YToxOntpOjA7YToxOntzOjc6ImNvbnRlbnQiO3M6MTAwMjoiQ09OVEFDVCBCb3N0b24gRmlyZSBEZXBhcnRtZW50IDYxNy0zNDMtMzYyOCBSRUFTT04gSWYgeW91IGhhdmUgdGVudCBzdHJ1Y3R1cmVzLCB0aGV5J2xsIG5lZWQgdG8gYmUgYXBwcm92ZWQgYnkgSW5zcGVjdGlvbmFsIFNlcnZpY2VzIGFuZCB0aGUgRmlyZSBEZXBhcnRtZW50LiBDT05UQUNUIEluc3BlY3Rpb25hbCBTZXJ2aWNlcyBEZXBhcnRtZW50IDYxNy02MzUtNTMwMCBSRUFTT04gWW91IG1heSBuZWVkIGEgbGV0dGVyIG9mIHN1cHBvcnQgZnJvbSBOZWlnaGJvcmhvb2QgU2VydmljZXMuIFlvdSBtYXkgYWxzbyBuZWVkIGEgY29udHJhY3QgZm9yIHdhc3RlIHJlbW92YWwuIENvbnRhY3QgTmVpZ2hib3Job29kIFNlcnZpY2VzIHRvIGZpbmQgb3V0LiBDT05UQUNUIE5laWdoYm9yaG9vZCBTZXJ2aWNlcyA2MTctNjM1LTM0ODUgc2hvdyBoaWRlIEFzIGEgdmVuZG9yIFN0ZXAgMSBCZWZvcmUgeW91IGdldCBzdGFydGVkIElmIHlvdSB3YW50IHRvIHNlbGwgcGFja2FnZWQgb3IgcHJvY2Vzc2VkIGZvb2QgYXQgYSBmYXJtZXJzIG1hcmtldCwgeW91J2xsIG5lZWQgdG8gYXBwbHkgZm9yIGEgdmVuZG9yIHBlcm1pdC4gWW91IGNhbiBhcHBseSBhcyBhIG5ldyBvciByZXR1cm5pbmcgdmVuZG9yLiBJZiB5b3UncmUgc2VsbGluZyBmb29kIGJ5IHdlaWdodCwgcGxlYXNlIGxlYXJuIGFib3V0IHRoZSBydWxlcyBmcm9tIHRoZSBXZWlnaHRzIGFuZCBNZWFzdXJlcyBEaXZpc2lvbi4gSWYgeW91IHF1ZXN0aW9ucywgeW91IGNhbiBjb250YWN0IHRoZSBkaXZpc2lvbiBhdCA2MTctNjM1LTUzMDAuIFN0ZXAgMiBDb21wbGV0ZSB0aGUgYXBwbGljYXRpb24gTmV3IHZlbmRvcnMgYW5kIHJldHVybmluZyB2ZW5kb3JzIHNob3VsZCBmaWxsIG91dCBvdXIgZmFybWVycyBtYXJrZXQgdmVuZG9yIHByb2ZpbGUgZm9ybS4gWW91IHdpbGwgbmVlZCB0byBpbmNsdWRlIGFueSByZXF1aXJlZCBkb2N1bWVudHMgd2UgYXNrIGZvciBpbiB0aGUgZm9ybSB3aXRoIHlvdXIgYXBwbGljYXRpb24uIjt9fX19fWk6NDthOjI6e3M6MjoiaWQiO3M6MzI6ImI0NmM3NTA5YmNlN2ExNTA5OGIxMmQ4YjMxZTRlZjE2IjtzOjg6ImRvY3VtZW50IjthOjM6e3M6NDoibmFtZSI7czoxNjk6InByb2plY3RzLzczODMxMzE3Mjc4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL29lb2ktcGlsb3QtZGF0YXN0b3JlXzE3MjYyNjU3OTU5MTAvYnJhbmNoZXMvMC9kb2N1bWVudHMvYjQ2Yzc1MDliY2U3YTE1MDk4YjEyZDhiMzFlNGVmMTYiO3M6MjoiaWQiO3M6MzI6ImI0NmM3NTA5YmNlN2ExNTA5OGIxMmQ4YjMxZTRlZjE2IjtzOjE3OiJkZXJpdmVkU3RydWN0RGF0YSI7YTo3OntzOjk6Imh0bWxUaXRsZSI7czo1NToiQXBwbHkgZm9yIHRoZSAyMDI0IEZvb2QgQ2FydCBQaWxvdCBQcm9ncmFtIHwgQm9zdG9uLmdvdiI7czo0OiJsaW5rIjtzOjExMjoiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9nb3Zlcm5tZW50L2NhYmluZXRzL2Vjb25vbWljLW9wcG9ydHVuaXR5LWFuZC1pbmNsdXNpb24vYXBwbHktMjAyNC1mb29kLWNhcnQtcGlsb3QtcHJvZ3JhbSI7czoxODoiZXh0cmFjdGl2ZV9hbnN3ZXJzIjthOjE6e2k6MDthOjE6e3M6NzoiY29udGVudCI7czoyNjY6IkJ1c2luZXNzIENlcnRpZmljYXRlIFRvIGZpbGUgYSByZWdpc3RlcmVkIGJ1c2luZXNzIGNlcnRpZmljYXRlIGFzIGEgZm9vZCBjYXJ0IHZlbmRvciwgeW91IG11c3QgaGF2ZSBhIHZhbGlkOiBoZWFsdGggcGVybWl0IGZpcmUgcGVybWl0IEhhd2tlcnMgYW5kIFBlZGRsZXJzIExpY2Vuc2UgY29tbWlzc2FyeSBraXRjaGVuIGFncmVlbWVudCBvciBsZXR0ZXIsIGFuZCBDZXJ0aWZpY2F0ZSBvZiBMaWFiaWxpdHkgSW5zdXJhbmNlLiBUaGUgZmlsaW5nIGZlZSBpcyAkNjUuIjt9fXM6NToidGl0bGUiO3M6NTU6IkFwcGx5IGZvciB0aGUgMjAyNCBGb29kIENhcnQgUGlsb3QgUHJvZ3JhbSB8IEJvc3Rvbi5nb3YiO3M6MTk6ImV4dHJhY3RpdmVfc2VnbWVudHMiO2E6MTp7aTowO2E6MTp7czo3OiJjb250ZW50IjtzOjE4MzA6IkNpdHkgb2YgQm9zdG9uIE1haW4gbWVudSBIZWxwIC8gMzExIEhvbWUgR3VpZGVzIHRvIEJvc3RvbiBEZXBhcnRtZW50cyBQdWJsaWMgTm90aWNlcyBQYXkgYW5kIGFwcGx5IEpvYnMgYW5kIGNhcmVlcnMgQnVzaW5lc3MgU3VwcG9ydCBFdmVudHMgTmV3cyBQbGFjZXMgQmFjayBDZW1ldGVyaWVzIENvbW11bml0eSBjZW50ZXJzIEhpc3RvcmljIERpc3RyaWN0cyBMaWJyYXJpZXMgTmVpZ2hib3Job29kcyBQYXJrcyBhbmQgcGxheWdyb3VuZHMgU2Nob29scyBHb3Zlcm5tZW50IEJhY2sgVGhlIE1heW9yJ3MgT2ZmaWNlIENpdHkgQ2xlcmsgQ2l0eSBDb3VuY2lsIEVsZWN0aW9ucyBCb2FyZHMgYW5kIGNvbW1pc3Npb25zIENpdHkgZ292ZXJubWVudCBvdmVydmlldyBGZWVkYmFjayBUb2dnbGUgTWVudSBCb3N0b24uZ292IE1heW9yIE1pY2hlbGxlIFd1IENpdHkgb2YgQm9zdG9uIFNlYWwgSW5mb3JtYXRpb24gYW5kIFNlcnZpY2VzIFB1YmxpYyBub3RpY2VzIEZlZWRiYWNrIEVuZ2xpc2ggRXNwYcOxb2wgU29vbWFhbGkgUG9ydHVndcOqcyBmcmFuw6dhaXMg566A5L2T5Lit5paHIFZpZXcgRGlzY2xhaW1lciBFc3Bhw7FvbCBLcmV5w7JsIGF5aXN5ZW4gUG9ydHVndcOqcyBmcmFuw6dhaXMg566A5L2T5Lit5paHIFRp4bq/bmcgVmnhu4d0INCg0YPRgdGB0LrQuNC5IFNvb21hYWxpINin2YTYudix2KjZitipIEFmcmlrYWFucyBzaHFpcCDhiqDhiJvhiK3hipsg2KfZhNi52LHYqNmK2Kkg1bDVodW11aXWgNWl1bYg2KLYsNix2KjYp9uM2KzYp9mGINiv24zZhCBFdXNrYXJhINCR0LXQu9Cw0YDRg9GB0LrQsNGPINC80L7QstCwIOCmrOCmvuCmguCmsuCmviDYqNuJ2LPYp9mG2LPZgtmJINCx0YrQu9Cz0LDRgNGB0LrQuCBjYXRhbMOgIEJpbmlzYXlhIENoaWNoZcW1YSDlub/kuJzor50g5buj5p2x6KmxIENvcnN1IEhydmF0c2tpIMSNZcWhdGluYSBkYW5zayBOZWRlcmxhbmRzIEVzcGVyYW50byBlZXN0aSBrZWVsIFBpbGlwaW5vIHN1b21pIGZyYW7Dp2FpcyDFjHN0ZnLDpGlzayBnYWxlZ28g4YOl4YOQ4YOg4YOX4YOj4YOa4YOYIOGDlOGDnOGDkCBEZXV0c2NoIM6VzrvOu863zr3Ouc66zqwg4KqX4KuB4Kqc4Kqw4Kq+4Kqk4KuAIEtyZXnDsmwgYXlpc3llbiDZh9mO2LHZkti02Y7ZhiDZh9mO2YjZktiz2Y4gyrvFjGxlbG8gSGF3YWnKu2kg16LWtNeR16jWtNeZ16og4KS54KS/4KSC4KSm4KWAIEx1cyBIbW9vYiBNYWd5YXIgw61zbGVuc2thIMOBc+G7pcyAc+G7pcyAIMOMZ2LDsiBiYWhhc2EgSW5kb25lc2lhIEdhZWlsZ2UgSXRhbGlhbm8g5pel5pys6KqeINio2KfYs9inINis2KfZiNinIOCyleCyqOCzjeCyqOCyoSDSmtCw0LfQsNKbINGC0ZbQu9GWIOGel+GetuGen+GetuGegeGfkuGemOGfguGemiDtlZzqta3snbgg2qnZiNix2YXYp9mG2KzbjCDQmtGL0YDQs9GL0Lcg0YLQuNC70Lgg4Lql4Lqy4LqnIExpbmd1YSBMYXRpbmEgbGF0dmllxaF1IHZhbG9kYSBsaWV0dXZpxbMga2FsYmEgTMOrdHplYnVlcmdlc2NoINC80LDQutC10LTQvtC90YHQutC4IG1hbGFnYXN5INio2YfYp9izINmF2YTYp9mK2Ygg4LSu4LSy4LSv4LS+4LSz4LSCIE1hbHRpIE3EgW9yaSDgpK7gpLDgpL7gpKDgpYAg0LzQvtC90LPQvtC7IOGAmeGAvOGAlOGAuuGAmeGArOGAheGAgOGArOGAuCDgpKjgpYfgpKrgpL7gpLLgpYAgbm9yc2sg2b7amtiq2Ygg2YHYp9ix2LPbjCBQb2xza2llIFBvcnR1Z3XDqnMg4Kiq4Kmw4Kic4Ki+4Kis4KmAIGxpbWJhIHJvbcOibsSDINCg0YPRgdGB0LrQuNC5IEdhZ2FuYSBmYSdhIFPEgW1vYSBHw6BpZGhsaWcg0KHRgNC/0YHQutC4IFNvdGhvIGNoaVNob25hINiz2YbajNmKIOC3g+C3kuC2guC3hOC2vSBzbG92ZW7EjWluYSI7fX1zOjExOiJkaXNwbGF5TGluayI7czoxNDoid3d3LmJvc3Rvbi5nb3YiO3M6ODoic25pcHBldHMiO2E6MTp7aTowO2E6Mjp7czo3OiJzbmlwcGV0IjtzOjE5OToiVGhpcyBzdW1tZXIsIHRoZSA8Yj5DaXR5IG9mIEJvc3RvbjwvYj4gd2lsbCBvcGVuIHVwIG5ldyBvcHBvcnR1bml0aWVzIGZvciBmb29kIGNhcnRzIGFuZCBtb2JpbGUgPGI+dmVuZG9yczwvYj4gdG8gc2VsbCBvbiA8Yj5Cb3N0b248L2I+IHN0cmVldHMgYW5kIGluIG5laWdoYm9yaG9vZHMgLi4uIDxiPkJlPC9iPiBzdXJlIHRvIHVzZSZuYnNwOy4uLiI7czoxNDoic25pcHBldF9zdGF0dXMiO3M6NzoiU1VDQ0VTUyI7fX19fX19czo5OiJ0b3RhbFNpemUiO2k6MjUzO3M6MTY6ImF0dHJpYnV0aW9uVG9rZW4iO3M6MzE4OiI2Z0h3NlFvTUNJV0pnYmdHRUotcGxxd0RFaVEyTnpBME9ETmxNaTB3TURBd0xUSTJZMkl0WWpNMk1DMDNORGMwTkRZell6QmhPVFVpQjBkRlRrVlNTVU1xcUFHcS1MTXR6cHEwTUtpdXR6RDU5ck10bjlhM0xiZVNyakNWa3NVd3hjdnpGLXVDc1MyZ2liTXRyZml6TFlDeW1pS3J4SW90eE1heE1OdWF0RERlbXJRdzFMS2RGWTJrdEREQzhKNFZvNENYSXM3bXRTX243WWd0MjQtYUlwYmVxQy1ROTdJd3BhNjNNTjZQbWlMb2dyRXRuTmEzTGN1YXRERDg5ck10anI2ZEZkSG10Uy0wa3E0d2c3S2FJdVR0aUMyamliTXRtZDZvTDVDa3REQ3V4SW90eDhheE1MVzNqQzB3QVEiO3M6MTM6Im5leHRQYWdlVG9rZW4iO3M6NzI6IjFrVFl3TTJNMlFETjNRek50QWpOeklXTGlObU55MENNd0FETXRFVFp6Z0ROd2NqTmtvUlR5cXN3UVlBdVFLX2hJc2dFMUVnQyI7czoxODoiZ3VpZGVkU2VhcmNoUmVzdWx0IjthOjI6e3M6MjA6InJlZmluZW1lbnRBdHRyaWJ1dGVzIjtOO3M6MTc6ImZvbGxvd1VwUXVlc3Rpb25zIjthOjU6e2k6MDtzOjQ5OiJIb3cgZG8gSSBnZXQgYSBwZXJtaXQgZm9yIGEgZm9vZCB0cnVjayBpbiBCb3N0b24/IjtpOjE7czo2MjoiSG93IGRvIEkgZ2V0IGNlcnRpZmllZCBhcyBhIG1pbm9yaXR5LW93bmVkIGJ1c2luZXNzIGluIEJvc3Rvbj8iO2k6MjtzOjQ0OiJIb3cgZG8gSSBnZXQgbXkgYnVzaW5lc3MgbGljZW5zZWQgaW4gQm9zdG9uPyI7aTozO3M6MjY6IldoYXQgaXMgdGhlIHZlbmRvciBwb3J0YWw/IjtpOjQ7czo1NDoiSG93IGRvIEkgcmVnaXN0ZXIgbXkgYnVzaW5lc3Mgd2l0aCB0aGUgQ2l0eSBvZiBCb3N0b24/Ijt9fXM6Nzoic3VtbWFyeSI7YTo0OntzOjExOiJzdW1tYXJ5VGV4dCI7czozODc6IlRvIGJlY29tZSBhIHZlbmRvciB3aXRoIHRoZSBDaXR5IG9mIEJvc3RvbiwgeW91IGNhbiBjcmVhdGUgYW4gYWNjb3VudCBvbiB0aGUgU3VwcGxpZXIgUG9ydGFsLiBUaGlzIHdpbGwgYWxsb3cgeW91IHRvIHNlZSBhbmQgYmlkIG9uIENpdHkgY29udHJhY3RzLiBZb3UgY2FuIGFsc28gc2VhcmNoIHRoZSBkYXRhYmFzZSB0byBmaW5kIGNlcnRpZmllZCBkaXZlcnNlIGFuZCBzbWFsbCBidXNpbmVzc2VzIGluIEJvc3Rvbi4gSWYgeW91IGFyZSBpbnRlcmVzdGVkIGluIHNlbGxpbmcgZm9vZCBhdCBhIGZhcm1lcnMgbWFya2V0LCB5b3Ugd2lsbCBuZWVkIHRvIGFwcGx5IGZvciBhIHZlbmRvciBwZXJtaXQuIFlvdSBjYW4gYXBwbHkgYXMgYSBuZXcgb3IgcmV0dXJuaW5nIHZlbmRvci4gCiI7czoxNjoic2FmZXR5QXR0cmlidXRlcyI7YToxOntpOjA7czowOiIiO31zOjE5OiJzdW1tYXJ5V2l0aE1ldGFkYXRhIjthOjM6e3M6Nzoic3VtbWFyeSI7czozODc6IlRvIGJlY29tZSBhIHZlbmRvciB3aXRoIHRoZSBDaXR5IG9mIEJvc3RvbiwgeW91IGNhbiBjcmVhdGUgYW4gYWNjb3VudCBvbiB0aGUgU3VwcGxpZXIgUG9ydGFsLiBUaGlzIHdpbGwgYWxsb3cgeW91IHRvIHNlZSBhbmQgYmlkIG9uIENpdHkgY29udHJhY3RzLiBZb3UgY2FuIGFsc28gc2VhcmNoIHRoZSBkYXRhYmFzZSB0byBmaW5kIGNlcnRpZmllZCBkaXZlcnNlIGFuZCBzbWFsbCBidXNpbmVzc2VzIGluIEJvc3Rvbi4gSWYgeW91IGFyZSBpbnRlcmVzdGVkIGluIHNlbGxpbmcgZm9vZCBhdCBhIGZhcm1lcnMgbWFya2V0LCB5b3Ugd2lsbCBuZWVkIHRvIGFwcGx5IGZvciBhIHZlbmRvciBwZXJtaXQuIFlvdSBjYW4gYXBwbHkgYXMgYSBuZXcgb3IgcmV0dXJuaW5nIHZlbmRvci4gCiI7czoxNjoiY2l0YXRpb25NZXRhZGF0YSI7YToxOntzOjk6ImNpdGF0aW9ucyI7YTo0OntpOjA7YTozOntzOjEwOiJzdGFydEluZGV4IjtpOjA7czo4OiJlbmRJbmRleCI7czozOiIxNDciO3M6Nzoic291cmNlcyI7YToxOntzOjE0OiJyZWZlcmVuY2VJbmRleCI7aTowO319aToxO2E6Mzp7czoxMDoic3RhcnRJbmRleCI7czozOiIxNDgiO3M6ODoiZW5kSW5kZXgiO3M6MzoiMjM4IjtzOjc6InNvdXJjZXMiO2E6MTp7czoxNDoicmVmZXJlbmNlSW5kZXgiO2k6MDt9fWk6MjthOjM6e3M6MTA6InN0YXJ0SW5kZXgiO3M6MzoiMjM5IjtzOjg6ImVuZEluZGV4IjtzOjM6IjM0MSI7czo3OiJzb3VyY2VzIjthOjE6e3M6MTQ6InJlZmVyZW5jZUluZGV4IjtpOjA7fX1pOjM7YTozOntzOjEwOiJzdGFydEluZGV4IjtzOjM6IjM0MiI7czo4OiJlbmRJbmRleCI7czozOiIzODUiO3M6Nzoic291cmNlcyI7YToxOntzOjE0OiJyZWZlcmVuY2VJbmRleCI7aTowO319fX1zOjEwOiJyZWZlcmVuY2VzIjthOjEwOntpOjA7YTo1OntzOjU6InRpdGxlIjtzOjQwOiJHZXQgWW91ciBCdXNpbmVzcyBDZXJ0aWZpZWQgfCBCb3N0b24uZ292IjtzOjg6ImRvY3VtZW50IjtzOjE2OToicHJvamVjdHMvNzM4MzEzMTcyNzg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2RhdGFTdG9yZXMvb2VvaS1waWxvdC1kYXRhc3RvcmVfMTcyNjI2NTc5NTkxMC9icmFuY2hlcy8wL2RvY3VtZW50cy9iZDdmYjRlOWE1NDRkODI1ZGQ2YmI1YTQ4MjIwYWRhZiI7czozOiJ1cmkiO3M6OTU6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvc3VwcGxpZXItYW5kLXdvcmtmb3JjZS1kaXZlcnNpdHkvZ2V0LXlvdXItYnVzaW5lc3MtY2VydGlmaWVkIjtzOjEzOiJjaHVua0NvbnRlbnRzIjthOjI6e3M6NzoiY29udGVudCI7czoxMjQ0OiJUaGlzIGFsbG93cyB5b3UgdG8gZWFzaWx5IHNhdmUgYW5kIHJldHVybiB0byB5b3VyIGFwcGxpY2F0aW9uIHdoaWxlIGl0J3MgaW4gcHJvZ3Jlc3MuIEJFR0lOIE9OTElORSBBUFBMSUNBVElPTiBQbGVhc2Ugbm90ZTogV2UgYWxzbyBzdWdnZXN0IHRoYXQgYmVjb21lIGEgdmVuZG9yIHdpdGggdGhlIENpdHkgb2YgQm9zdG9uIGJ5IGNyZWF0aW5nIGFuIGFjY291bnQgb24gdGhlIFN1cHBsaWVyIFBvcnRhbC4gWW91ciB2ZW5kb3IgYWNjb3VudCB3aWxsIGFsbG93IHlvdSB0byBzZWUgYW5kIGJpZCBvbiBDaXR5IGNvbnRyYWN0cy4gS2VlcCBpbiBtaW5kIFlvdSBjYW4gc2VhcmNoIG91ciBkYXRhYmFzZSB0byBmaW5kIGNlcnRpZmllZCBkaXZlcnNlIGFuZCBzbWFsbCBidXNpbmVzc2VzIGluIEJvc3Rvbi4gV2UgYWxzbyBoYXZlIGEgbGlzdCBvZiBhbGwgb3BlbiBiaWQgcHJvamVjdHMgaW4gdGhlIENpdHkgb2YgQm9zdG9uLiBZb3UgY2FuIGdldCBhIHBhaWQgbWFpbCBzdWJzY3JpcHRpb24sIG9yIHNlZSBhIGxpc3Qgb2YgY3VycmVudCBiaWRzIG9ubGluZS4gUmVsYXRlZCBSZXNvdXJjZXMgUmVsYXRlZCBSZXNvdXJjZXMgSG93IHRvIGFwcGx5IGZvciBhIENpdHkgb2YgQm9zdG9uIGJ1c2luZXNzIGNlcnRpZmljYXRlIFNpZ24gdXAgZm9yIG91ciBuZXdzbGV0dGVyIENvbnRhY3Q6IFN1cHBsaWVyIERpdmVyc2l0eSBTaWduIHVwIGZvciBvdXIgU3VwcGxpZXIgRGl2ZXJzaXR5IG5ld3NsZXR0ZXIgdG8gbGVhcm4gYWJvdXQgdXBjb21pbmcgQ2l0eSBjb250cmFjdGluZyBvcHBvcnR1bml0aWVzLCBldmVudHMsIGFuZCB3b3Jrc2hvcHMuIFlvdXIgRW1haWwgQWRkcmVzcyBaaXAgQ29kZSBHb3RjaGEgU2lnbiBVcCBIYXZlIHF1ZXN0aW9ucz8gQ29udGFjdDogc3VwcGxpZXIgZGl2ZXJzaXR5IHByb2dyYW0gNjE3LTYzNS00NTExIGJ1c2luZXNzY2VydGlmaWNhdGlvbkBib3N0b24uZ292IFpPT00gQ0VSVElGSUNBVElPTiBIT1VSUyBKb2luIG91ciB3ZWVrbHkgTVdCRSBab29tIENlcnRpZmljYXRpb24gSG91cnMsIGV2ZXJ5IFdlZG5lc2RheSBmcm9tIDExIGFtIC0gMSBwbTogam9pbiBjZXJ0aWZpY2F0aW9uIG9mZmljZSBIb3VycyBQcm92aWRlIFlvdXIgRmVlZGJhY2sgQmFjayB0byB0b3AgRm9vdGVyIG1lbnUgUHJpdmFjeSBQb2xpY3kgQ29udGFjdCB1cyBKb2JzIFB1YmxpYyByZWNvcmRzIExhbmd1YWdlIGFuZCBEaXNhYmlsaXR5IEFjY2VzcyBCT1M6MzExIC0gUmVwb3J0IGFuIGlzc3VlICI7czoxNDoicGFnZUlkZW50aWZpZXIiO047fXM6OToiZXh0cmFJbmZvIjthOjE6e3M6MTQ6InJlbGV2YW5jZVNjb3JlIjtkOjAuODt9fWk6MTthOjU6e3M6NToidGl0bGUiO3M6NDk6IkhvdyBUbyBUYWtlIFBhcnQgSW4gQSBGYXJtZXJzIE1hcmtldCB8IEJvc3Rvbi5nb3YiO3M6ODoiZG9jdW1lbnQiO3M6MTY5OiJwcm9qZWN0cy83MzgzMTMxNzI3ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9vZW9pLXBpbG90LWRhdGFzdG9yZV8xNzI2MjY1Nzk1OTEwL2JyYW5jaGVzLzAvZG9jdW1lbnRzLzBmYTg1OWVhYmM0MTFlN2Y1M2Y1ZWIzMzBmZWQyNGVlIjtzOjM6InVyaSI7czo3NToiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9mb29kLWFjY2Vzcy9ob3ctdGFrZS1wYXJ0LWZhcm1lcnMtbWFya2V0IjtzOjEzOiJjaHVua0NvbnRlbnRzIjthOjI6e3M6NzoiY29udGVudCI7czoxMTk1OiJDT05UQUNUIENvbnN1bWVyIEFmZmFpcnMgYW5kIExpY2Vuc2luZyA2MTctNjM1LTQxNjUgUkVBU09OIFlvdSdsbCBuZWVkIGEgcGVybWl0IGlmIHlvdSBwbGFuIHRvIHVzZSBhIHBvcnRhYmxlIGdlbmVyYXRvci4gWW91IG1heSBhbHNvIG5lZWQgYSBwZXJtaXQgaWYgeW91IHBsYW4gdG8gaG9sZCBjb29raW5nIGRlbW9uc3RyYXRpb25zLiBDT05UQUNUIEJvc3RvbiBGaXJlIERlcGFydG1lbnQgNjE3LTM0My0zNjI4IFJFQVNPTiBJZiB5b3UgaGF2ZSB0ZW50IHN0cnVjdHVyZXMsIHRoZXknbGwgbmVlZCB0byBiZSBhcHByb3ZlZCBieSBJbnNwZWN0aW9uYWwgU2VydmljZXMgYW5kIHRoZSBGaXJlIERlcGFydG1lbnQuIENPTlRBQ1QgSW5zcGVjdGlvbmFsIFNlcnZpY2VzIERlcGFydG1lbnQgNjE3LTYzNS01MzAwIFJFQVNPTiBZb3UgbWF5IG5lZWQgYSBsZXR0ZXIgb2Ygc3VwcG9ydCBmcm9tIE5laWdoYm9yaG9vZCBTZXJ2aWNlcy4gWW91IG1heSBhbHNvIG5lZWQgYSBjb250cmFjdCBmb3Igd2FzdGUgcmVtb3ZhbC4gQ29udGFjdCBOZWlnaGJvcmhvb2QgU2VydmljZXMgdG8gZmluZCBvdXQuIENPTlRBQ1QgTmVpZ2hib3Job29kIFNlcnZpY2VzIDYxNy02MzUtMzQ4NSBzaG93IGhpZGUgQXMgYSB2ZW5kb3IgU3RlcCAxIEJlZm9yZSB5b3UgZ2V0IHN0YXJ0ZWQgSWYgeW91IHdhbnQgdG8gc2VsbCBwYWNrYWdlZCBvciBwcm9jZXNzZWQgZm9vZCBhdCBhIGZhcm1lcnMgbWFya2V0LCB5b3UnbGwgbmVlZCB0byBhcHBseSBmb3IgYSB2ZW5kb3IgcGVybWl0LiBZb3UgY2FuIGFwcGx5IGFzIGEgbmV3IG9yIHJldHVybmluZyB2ZW5kb3IuIElmIHlvdSdyZSBzZWxsaW5nIGZvb2QgYnkgd2VpZ2h0LCBwbGVhc2UgbGVhcm4gYWJvdXQgdGhlIHJ1bGVzIGZyb20gdGhlIFdlaWdodHMgYW5kIE1lYXN1cmVzIERpdmlzaW9uLiBJZiB5b3UgcXVlc3Rpb25zLCB5b3UgY2FuIGNvbnRhY3QgdGhlIGRpdmlzaW9uIGF0IDYxNy02MzUtNTMwMC4gU3RlcCAyIENvbXBsZXRlIHRoZSBhcHBsaWNhdGlvbiBOZXcgdmVuZG9ycyBhbmQgcmV0dXJuaW5nIHZlbmRvcnMgc2hvdWxkIGZpbGwgb3V0IG91ciBmYXJtZXJzIG1hcmtldCB2ZW5kb3IgcHJvZmlsZSBmb3JtLiBZb3Ugd2lsbCBuZWVkIHRvIGluY2x1ZGUgYW55IHJlcXVpcmVkIGRvY3VtZW50cyB3ZSBhc2sgZm9yIGluIHRoZSBmb3JtIHdpdGggeW91ciBhcHBsaWNhdGlvbi4gIjtzOjE0OiJwYWdlSWRlbnRpZmllciI7Tjt9czo5OiJleHRyYUluZm8iO2E6MTp7czoxNDoicmVsZXZhbmNlU2NvcmUiO2Q6MC41O319aToyO2E6NTp7czo1OiJ0aXRsZSI7czo1MjoiSG93IFRvIEFwcGx5IEZvciBBIEJ1c2luZXNzIENlcnRpZmljYXRlIHwgQm9zdG9uLmdvdiI7czo4OiJkb2N1bWVudCI7czoxNjk6InByb2plY3RzLzczODMxMzE3Mjc4OC9sb2NhdGlvbnMvZ2xvYmFsL2NvbGxlY3Rpb25zL2RlZmF1bHRfY29sbGVjdGlvbi9kYXRhU3RvcmVzL29lb2ktcGlsb3QtZGF0YXN0b3JlXzE3MjYyNjU3OTU5MTAvYnJhbmNoZXMvMC9kb2N1bWVudHMvY2FiM2JkMTRkZmNjMGFiYThjYWUyMGZlMWQ3ODQwZTciO3M6MzoidXJpIjtzOjc2OiJodHRwczovL3d3dy5ib3N0b24uZ292L2RlcGFydG1lbnRzL2NpdHktY2xlcmsvaG93LWFwcGx5LWJ1c2luZXNzLWNlcnRpZmljYXRlIjtzOjEzOiJjaHVua0NvbnRlbnRzIjthOjI6e3M6NzoiY29udGVudCI7czoxMjIwOiJXZSBhY2NlcHQgY2FzaCwgY3JlZGl0IGNhcmRzLCBwaW5sZXNzIGRlYml0IGNhcmRzLCBhbmQgY2hlY2tzIG9yIG1vbmV5IG9yZGVycyBtYWRlIHBheWFibGUgdG8gdGhlIENpdHkgb2YgQm9zdG9uLiBJZiB5b3UgdXNlIGEgY3JlZGl0IGNhcmQgb3IgcGlubGVzcyBkZWJpdCBjYXJkLCB0aGVyZSBpcyBhIG5vbi1yZWZ1bmRhYmxlIHNlcnZpY2UgZmVlIG9mIDIuNSUgb2YgdGhlIHRvdGFsIHBheW1lbnQsIHdpdGggYSAkMSBtaW5pbXVtLiBUaGlzIGZlZSBpcyBwYWlkIHRvIHRoZSBjYXJkIHByb2Nlc3NvciBhbmQgbm90IGtlcHQgYnkgdGhlIENpdHkuIFBsZWFzZSBub3RlOiBpZiB5b3VyIGRlYml0IGNhcmQgcmVxdWlyZXMgeW91IHRvIGVudGVyIHlvdXIgcGluIHRvIHByb2Nlc3MgYSBwYXltZW50LCB5b3UgQ0FOTk9UIHVzZSBpdCB0byBwYXkgeW91ciBmZWUuIEFwcGx5aW5nIGJ5IG1haWw/IElmIHlvdSBzZW5kIHlvdXIgcGF5bWVudCBieSBtYWlsLCBwbGVhc2UgaW5jbHVkZSBhIGNoZWNrIG9yIG1vbmV5IG9yZGVyIG1hZGUgcGF5YWJsZSB0byB0aGUgQ2l0eSBvZiBCb3N0b24uIFN0ZXAgMiBNYWtlIHN1cmUgeW91IGhhdmUgYWxsIHlvdXIgaW5mb3JtYXRpb24gRm9yIHNvbWUgYnVzaW5lc3Nlcywgd2UgcmVxdWlyZSBvdGhlciBkb2N1bWVudHMuIFRvIGZpbGUgYSBidXNpbmVzcyByZWdpc3RyYXRpb24gYXMgYSBmb29kIHRydWNrIHZlbmRvciwgeW91IG11c3QgaGF2ZSBhIHZhbGlkOiBoZWFsdGggcGVybWl0IGZpcmUgcGVybWl0IEhhd2tlcnMgYW5kIFBlZGRsZXJzIExpY2Vuc2UgY29tbWlzc2FyeSBraXRjaGVuIGFncmVlbWVudCBvciBsZXR0ZXIsIGFuZCBDZXJ0aWZpY2F0ZSBvZiBMaWFiaWxpdHkgSW5zdXJhbmNlLiBUbyBmaWxlIGEgYnVzaW5lc3MgcmVnaXN0cmF0aW9uIGZvciBzaG9ydC10ZXJtIHJlbnRhbCBob3VzaW5nLCB5b3UgbXVzdCBoYXZlIGEgcmVnaXN0cmF0aW9uIG51bWJlciBmcm9tIEluc3BlY3Rpb25hbCBTZXJ2aWNlcy4gWW91J2xsIG5lZWQgdG8gZ2l2ZSB1cyBhIGNvcHkgb2YgdGhlIHJlZ2lzdHJhdGlvbiBudW1iZXIgZm9ybS4gWW91IGNhbiBsZWFybiBtb3JlIGFib3V0IHNob3J0LXRlcm0gcmVudGFscyBvbmxpbmUuIElmIHlvdSBwbGFuIHRvIG9wZW4gYSBkYXljYXJlIGJ1c2luZXNzLCB5b3UgbXVzdCBnaXZlIHVzIGEgY29weSBvZiBhIHN0YXRlLWlzc3VlZCBkYXljYXJlIHByb3ZpZGVyIGxpY2Vuc2UuICI7czoxNDoicGFnZUlkZW50aWZpZXIiO047fXM6OToiZXh0cmFJbmZvIjthOjE6e3M6MTQ6InJlbGV2YW5jZVNjb3JlIjtkOjAuNTt9fWk6MzthOjU6e3M6NToidGl0bGUiO3M6NTI6IkhvdyBUbyBBcHBseSBGb3IgQSBCdXNpbmVzcyBDZXJ0aWZpY2F0ZSB8IEJvc3Rvbi5nb3YiO3M6ODoiZG9jdW1lbnQiO3M6MTY5OiJwcm9qZWN0cy83MzgzMTMxNzI3ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9vZW9pLXBpbG90LWRhdGFzdG9yZV8xNzI2MjY1Nzk1OTEwL2JyYW5jaGVzLzAvZG9jdW1lbnRzL2NhYjNiZDE0ZGZjYzBhYmE4Y2FlMjBmZTFkNzg0MGU3IjtzOjM6InVyaSI7czo3NjoiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9jaXR5LWNsZXJrL2hvdy1hcHBseS1idXNpbmVzcy1jZXJ0aWZpY2F0ZSI7czoxMzoiY2h1bmtDb250ZW50cyI7YToyOntzOjc6ImNvbnRlbnQiO3M6MTEwNjoiSWYgeW91IHVzZSBhIGNyZWRpdCBjYXJkIG9yIHBpbmxlc3MgZGViaXQgY2FyZCwgdGhlcmUgaXMgYSBub24tcmVmdW5kYWJsZSBzZXJ2aWNlIGZlZSBvZiAyLjUlIG9mIHRoZSB0b3RhbCBwYXltZW50LCB3aXRoIGEgJDEgbWluaW11bS4gVGhpcyBmZWUgaXMgcGFpZCB0byB0aGUgY2FyZCBwcm9jZXNzb3IgYW5kIG5vdCBrZXB0IGJ5IHRoZSBDaXR5LiBQbGVhc2Ugbm90ZTogaWYgeW91ciBkZWJpdCBjYXJkIHJlcXVpcmVzIHlvdSB0byBlbnRlciB5b3VyIHBpbiB0byBwcm9jZXNzIGEgcGF5bWVudCwgeW91IENBTk5PVCB1c2UgaXQgdG8gcGF5IHlvdXIgZmVlLiBBcHBseWluZyBieSBtYWlsPyBJZiB5b3Ugc2VuZCB5b3VyIHBheW1lbnQgYnkgbWFpbCwgcGxlYXNlIGluY2x1ZGUgYSBjaGVjayBvciBtb25leSBvcmRlciBtYWRlIHBheWFibGUgdG8gdGhlIENpdHkgb2YgQm9zdG9uLiBTdGVwIDIgTWFrZSBzdXJlIHlvdSBoYXZlIGFsbCB5b3VyIGluZm9ybWF0aW9uIEZvciBzb21lIGJ1c2luZXNzZXMsIHdlIHJlcXVpcmUgb3RoZXIgZG9jdW1lbnRzLiBUbyBmaWxlIGEgYnVzaW5lc3MgcmVnaXN0cmF0aW9uIGFzIGEgZm9vZCB0cnVjayB2ZW5kb3IsIHlvdSBtdXN0IGhhdmUgYSB2YWxpZDogaGVhbHRoIHBlcm1pdCBmaXJlIHBlcm1pdCBIYXdrZXJzIGFuZCBQZWRkbGVycyBMaWNlbnNlIGNvbW1pc3Nhcnkga2l0Y2hlbiBhZ3JlZW1lbnQgb3IgbGV0dGVyLCBhbmQgQ2VydGlmaWNhdGUgb2YgTGlhYmlsaXR5IEluc3VyYW5jZS4gVG8gZmlsZSBhIGJ1c2luZXNzIHJlZ2lzdHJhdGlvbiBmb3Igc2hvcnQtdGVybSByZW50YWwgaG91c2luZywgeW91IG11c3QgaGF2ZSBhIHJlZ2lzdHJhdGlvbiBudW1iZXIgZnJvbSBJbnNwZWN0aW9uYWwgU2VydmljZXMuIFlvdSdsbCBuZWVkIHRvIGdpdmUgdXMgYSBjb3B5IG9mIHRoZSByZWdpc3RyYXRpb24gbnVtYmVyIGZvcm0uIFlvdSBjYW4gbGVhcm4gbW9yZSBhYm91dCBzaG9ydC10ZXJtIHJlbnRhbHMgb25saW5lLiBJZiB5b3UgcGxhbiB0byBvcGVuIGEgZGF5Y2FyZSBidXNpbmVzcywgeW91IG11c3QgZ2l2ZSB1cyBhIGNvcHkgb2YgYSBzdGF0ZS1pc3N1ZWQgZGF5Y2FyZSBwcm92aWRlciBsaWNlbnNlLiAiO3M6MTQ6InBhZ2VJZGVudGlmaWVyIjtOO31zOjk6ImV4dHJhSW5mbyI7YToxOntzOjE0OiJyZWxldmFuY2VTY29yZSI7ZDowLjQ7fX1pOjQ7YTo1OntzOjU6InRpdGxlIjtzOjQ5OiJIb3cgVG8gVGFrZSBQYXJ0IEluIEEgRmFybWVycyBNYXJrZXQgfCBCb3N0b24uZ292IjtzOjg6ImRvY3VtZW50IjtzOjE2OToicHJvamVjdHMvNzM4MzEzMTcyNzg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2RhdGFTdG9yZXMvb2VvaS1waWxvdC1kYXRhc3RvcmVfMTcyNjI2NTc5NTkxMC9icmFuY2hlcy8wL2RvY3VtZW50cy8wZmE4NTllYWJjNDExZTdmNTNmNWViMzMwZmVkMjRlZSI7czozOiJ1cmkiO3M6NzU6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvZm9vZC1hY2Nlc3MvaG93LXRha2UtcGFydC1mYXJtZXJzLW1hcmtldCI7czoxMzoiY2h1bmtDb250ZW50cyI7YToyOntzOjc6ImNvbnRlbnQiO3M6MTMwNDoiVmVuZG9yIFByb2ZpbGUgRm9ybSBJZiB5b3UgYWxyZWFkeSBmaWxsZWQgb3V0IGEgdmVuZG9yIHByb2ZpbGUgYnV0IHlvdSB3YW50IHRvIHNlbGwgeW91ciBwcm9kdWN0cyBhdCBvdGhlciBmYXJtZXJzIG1hcmtldHMsIHBsZWFzZSBlbWFpbCBUcmFjeSBTZW5lc2NoYWwgYXQgSW5zcGVjdGlvbmFsIFNlcnZpY2VzOiB0cmFjeS5zZW5lc2NoYWxAYm9zdG9uLmdvdiBTdGVwIDMgRmluZCBvdXQgd2hhdCB5b3Ugd2lsbCBoYXZlIHRvIHBheSBCb3N0b24gSW5zcGVjdGlvbmFsIFNlcnZpY2VzIERlcGFydG1lbnQgKElTRCkgY2hhcmdlcyBhICQxMDAgaGVhbHRoIGZlZSBwZXIgRmFybWVycyBNYXJrZXQgdmVuZG9yLiBTdGVwIDQgU3VibWl0IHlvdXIgYXBwbGljYXRpb24gUmV0dXJuIHRoZSBmb3JtIHRvIHRoZSBmYXJtZXJzIG1hcmtldCBtYW5hZ2VyIG9mIHRoZSBsb2NhdGlvbiB3aGVyZSB5b3UgYXJlIGFwcGx5aW5nLiBZb3VyIGFwcGxpY2F0aW9uIHdpbGwgYmUgcHJvY2Vzc2VkIHdpdGggSW5zcGVjdGlvbmFsIFNlcnZpY2VzOiBJbnNwZWN0aW9uYWwgU2VydmljZXMgRGVwYXJ0bWVudCAxMDEwIE1hc3NhY2h1c2V0dHMgQXZlLiwgQm9zdG9uLCBNQSAwMjExOCBPZmZpY2UgaG91cnM6IE1vbmRheSB0aHJvdWdoIEZyaWRheSwgOCBhbSAtIDQgcG0gS2VlcCBpbiBtaW5kIFNlbGxpbmcgd2luZSBZb3UgY2FuIG9ubHkgc2VsbCBib3R0bGVkIHdpbmUgYXQgYSBmYXJtZXJzIG1hcmtldCBpZiB0aGUgbWFya2V0IGlzIG9uIHByaXZhdGUgcHJvcGVydHkuIENhbGwgdGhlIE1hc3NhY2h1c2V0dHMgRGVwYXJ0bWVudCBvZiBBZ3JpY3VsdHVyYWwgUmVzb3VyY2VzIGF0IDYxNy02MjYtMTc1NCBmb3IgbW9yZSBpbmZvcm1hdGlvbi4gU3RhdGUgRm9vZCBQcm90ZWN0aW9uIFByb2dyYW0gVGhlIFN0YXRlIEZvb2QgUHJvdGVjdGlvbiBQcm9ncmFtIGVuc3VyZXMgYSBzYWZlIGFuZCB3aG9sZXNvbWUgZm9vZCBzdXBwbHkgaW4gTWFzc2FjaHVzZXR0cy4gQ29udGFjdCB0aGUgcHJvZ3JhbSBhdCA2MTctOTgzLTY3MTIgb3IgRlBQLkRQSEBzdGF0ZS5tYS51cy4gQ29udGFjdDogRm9vZCBKdXN0aWNlIDYxNy02MzUtMzcxNyBzZW5kIGFuIGVtYWlsIDEgQ2l0eSBIYWxsIFNxdWFyZSBSb29tIDgwNCBCb3N0b24sIE1BIDAyMjAxIFVuaXRlZCBTdGF0ZXMgUHJvdmlkZSBZb3VyIEZlZWRiYWNrIEJhY2sgdG8gdG9wIEZvb3RlciBtZW51IFByaXZhY3kgUG9saWN5IENvbnRhY3QgdXMgSm9icyBQdWJsaWMgcmVjb3JkcyBMYW5ndWFnZSBhbmQgRGlzYWJpbGl0eSBBY2Nlc3MgQk9TOjMxMSAtIFJlcG9ydCBhbiBpc3N1ZSAiO3M6MTQ6InBhZ2VJZGVudGlmaWVyIjtOO31zOjk6ImV4dHJhSW5mbyI7YToxOntzOjE0OiJyZWxldmFuY2VTY29yZSI7ZDowLjM7fX1pOjU7YTo1OntzOjU6InRpdGxlIjtzOjQ5OiJIb3cgVG8gVGFrZSBQYXJ0IEluIEEgRmFybWVycyBNYXJrZXQgfCBCb3N0b24uZ292IjtzOjg6ImRvY3VtZW50IjtzOjE2OToicHJvamVjdHMvNzM4MzEzMTcyNzg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2RhdGFTdG9yZXMvb2VvaS1waWxvdC1kYXRhc3RvcmVfMTcyNjI2NTc5NTkxMC9icmFuY2hlcy8wL2RvY3VtZW50cy8wZmE4NTllYWJjNDExZTdmNTNmNWViMzMwZmVkMjRlZSI7czozOiJ1cmkiO3M6NzU6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvZm9vZC1hY2Nlc3MvaG93LXRha2UtcGFydC1mYXJtZXJzLW1hcmtldCI7czoxMzoiY2h1bmtDb250ZW50cyI7YToyOntzOjc6ImNvbnRlbnQiO3M6MTEyNDoiWW91IG5lZWQgdG8gZmluZCBhIGxvY2F0aW9uIGFuZCBhIG1hbmFnZXIgYmVmb3JlIHlvdSBjYW4gc3RhcnQgYSBmYXJtZXJzIG1hcmtldC4gSWYgeW91IGFyZSBsb29raW5nIHRvIHN0YXJ0IGEgbmV3IGZhcm1lcnMgbWFya2V0IOKAlCBvciByZW5ldyBhbiBleGlzdGluZyBtYXJrZXQg4oCUIHlvdSBuZWVkIHRvIGNvbXBsZXRlIG91ciBtYW5hZ2VyIGZvcm06IEZhcm1lcnMgTWFya2V0IE1hbmFnZXIgRm9ybSBTdGVwIDIgR2l2ZSB1cyB5b3VyIGFwcGxpY2F0aW9uIFlvdSBuZWVkIHRvIHRlbGwgdXMgd2hhdCB0eXBlIG9mIHZlbmRvcnMgeW91IHBsYW4gdG8gaGF2ZSBhdCB5b3VyIG1hcmtldCBhbmQgZ2l2ZSB1cyB0aGVpciB2ZW5kb3IgcHJvZmlsZXMuIFRoZSBmb3JtIGxpc3RzIHdoYXQgb3RoZXIgZG9jdW1lbnRzIHlvdSBtYXkgbmVlZCBnaXZlIHVzIHdpdGggeW91ciBhcHBsaWNhdGlvbi4gWW91IGNhbiBtYWlsIG9yIGJyaW5nIGV2ZXJ5dGhpbmcgdG86IE9mZmljZSBvZiBGb29kIEFjY2VzcyAxIENpdHkgSGFsbCBTcXVhcmUsIFJvb20gODA2LCBCb3N0b24sIE1BIDAyMjAxIE9mZmljZSBob3VyczogTW9uZGF5IHRocm91Z2ggRnJpZGF5LCA5IGFtIC0gNSBwbSBTdGVwIDMgR2V0IGFueSBzcGVjaWFsIHBlcm1pdHMgeW91IG1heSBuZWVkIEFmdGVyIHlvdSBzdWJtaXQgeW91ciBhcHBsaWNhdGlvbiwgd2UnbGwgdGVsbCB5b3UgaWYgeW91IG5lZWQgdG8gZ2V0IGFueSBtb3JlIHBlcm1pdHMuIFlvdSBtYXkgbmVlZCB0byBnZXQgcGVybWl0cyBmb3IgdGhlIHNwZWNpYWwgc2l0dWF0aW9ucyBsaXN0ZWQgYmVsb3c6IFJFQVNPTiBDT05UQUNUIFJFQVNPTiBZb3UgbmVlZCBhIFB1YmxpYyBXYXlzIHBlcm1pdCBpZiB5b3VyIGZhcm1lcnMgbWFya2V0IGlzIG9uIGEgc2lkZXdhbGsuIENPTlRBQ1QgUHVibGljIFdvcmtzIDYxNy02MzUtNDkwMCBSRUFTT04gWW91IG5lZWQgYSBQYXJrcyBQZXJtaXQgaWYgdGhlIG1hcmtldCBpcyBpbiBhIENpdHkgcGFyay4gQ09OVEFDVCBQYXJrcyBEZXBhcnRtZW50IDYxNy02MzUtNDUwNSBSRUFTT04gSWYgeW91IHBsYW4gdG8gaGF2ZSBhbXBsaWZpZWQgbXVzaWMgcGxheWluZywgeW91IG5lZWQgYW4gZW50ZXJ0YWlubWVudCBsaWNlbnNlLiAiO3M6MTQ6InBhZ2VJZGVudGlmaWVyIjtOO31zOjk6ImV4dHJhSW5mbyI7YToxOntzOjE0OiJyZWxldmFuY2VTY29yZSI7ZDowLjM7fX1pOjY7YTo1OntzOjU6InRpdGxlIjtzOjQwOiJHZXQgWW91ciBCdXNpbmVzcyBDZXJ0aWZpZWQgfCBCb3N0b24uZ292IjtzOjg6ImRvY3VtZW50IjtzOjE2OToicHJvamVjdHMvNzM4MzEzMTcyNzg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2RhdGFTdG9yZXMvb2VvaS1waWxvdC1kYXRhc3RvcmVfMTcyNjI2NTc5NTkxMC9icmFuY2hlcy8wL2RvY3VtZW50cy9iZDdmYjRlOWE1NDRkODI1ZGQ2YmI1YTQ4MjIwYWRhZiI7czozOiJ1cmkiO3M6OTU6Imh0dHBzOi8vd3d3LmJvc3Rvbi5nb3YvZGVwYXJ0bWVudHMvc3VwcGxpZXItYW5kLXdvcmtmb3JjZS1kaXZlcnNpdHkvZ2V0LXlvdXItYnVzaW5lc3MtY2VydGlmaWVkIjtzOjEzOiJjaHVua0NvbnRlbnRzIjthOjI6e3M6NzoiY29udGVudCI7czoxOTY3OiLZgtivINmK2KTYr9mKINmH2LDYpyDYpdmE2Ykg2YbYtSDZhdiq2LHYrNmFINi62YrYsSDYr9mC2YrZgiDYjCDYo9mIINij2K7Yt9in2KEg2KPYrtix2Ykg2YHZiiDYp9mE2LXZiNixINmI2KfZhNmF2LjZh9ixINin2YTYudin2YUg2YTZhNi12YHYrdin2Kog2KfZhNmF2KrYsdis2YXYqS4g2YjZhdi5INiw2YTZgyDYjCDZitmF2YPZhtmDINin2YTYpdio2YTYp9i6INi52YYg2KrYsdis2YXYp9iqINi62YrYsSDYtdit2YrYrdipINij2Ygg2K/ZiNmGINin2YTZhdiz2KrZiNmJINin2YTZhdi32YTZiNioINmI2KfZhNmF2LPYp9mH2YXYqSDZgdmKINiq2LHYrNmF2KfYqiDYo9mB2LbZhCDYqNin2LPYqtiu2K/Yp9mFINin2YTYqtix2KzZhdipINmF2YYgR29vZ2xlLiDYo9mI2YTYp9mLINiMINmF2LHYsSDYp9mE2YXYp9mI2LMg2YHZiNmCINij2Yog2YbYtSDZitit2KrZiNmKINi52YTZiSDYrti32KMg2YjYp9mG2YLYsSDYudmE2YrZhy4g2YrYrNioINij2YYg2YrYuNmH2LEg2YXYsdio2Lkg2YXZhtio2KvZgi4g2KjYudivINiw2YTZgyDYjCDYp9mG2YLYsSDZgdmI2YIg4oCc2KfZhNmF2LPYp9mH2YXYqSDYqNiq2LHYrNmF2Kkg2KPZgdi22YTigJwuINin2YbZgtixINmG2YLYsdmL2Kcg2YXYstiv2YjYrNmL2Kcg2YHZiNmCINmF2YbYt9mC2Kkg2KfZhNmG2KfZgdiw2Kkg2KfZhNmF2YbYqNir2YLYqSDYp9mE2KrZiiDYqtmC2YjZhCDigJzYp9mG2YLYsSDZgdmI2YIg2YPZhNmF2Kkg2YTZhNit2LXZiNmEINi52YTZiSDYqtix2KzZhdin2Kog2KjYr9mK2YTYqSDYjCDYo9mIINin2YbZgtixINmG2YLYsdmL2Kcg2YXYstiv2YjYrNmL2Kcg2YTZhNiq2LnYr9mK2YQg2YXYqNin2LTYsdip4oCcLiDZgtmFINio2KXYrNix2KfYoSDYqti52K/ZitmE2KfYqtmDINmF2KjYp9i02LHYqSDYudmE2Ykg2KfZhNmG2LUg2KfZhNmF2YjYrNmI2K8g2YHZiiDZhdix2KjYuSDYp9mE2YbYtS4g2KPYrtmK2LHZi9inINiMINin2LbYuti3INi52YTZiSDZhdiz2KfZh9mF2Kkg2YTZhNmF2LPYp9mH2YXYqSDYqNiq2LnYr9mK2YTYp9iq2YMg2KfZhNmF2YLYqtix2K3YqS4g2YrZhdmD2YYg2KfZhNi52KvZiNixINi52YTZiSDZhdiy2YrYryDZhdmGINin2YTZhdi52YTZiNmF2KfYqiDYrdmI2YQg2KfZhNmF2LPYp9mH2YXYqSDZgdmKINiq2LHYrNmF2KkgR29vZ2xlINmH2YbYpy4g2YrYsdis2Ykg2YXZhNin2K3YuNipINij2YYgRG9JVCDZhNinINiq2KrYrdmD2YUg2YHZiiDYp9mE2LnZhdmE2YrYqSDYp9mE2KrZiiDZitiq2YUg2YXZhiDYrtmE2KfZhNmH2Kcg2K/ZhdisINin2YTYqtix2KzZhdin2Kog2KfZhNmF2LPYp9mH2YXYqSDZgdmKINmF2KrYsdis2YUg2KfZhNmI2YrYqCDZhdmGIEdvb2dsZS4g2LLZhSDZhdiv2YrZhtipINio2YjYs9i32YYg2KjYqtit2LPZitmGINis2YjYr9ipINmI2KfYqtiz2KfYuSDYp9mE2YXYrdiq2YjZiSDZhdiq2LnYr9ivINin2YTZhNi62KfYqiDYudmE2Ykg2YXZiNmC2LnZhtinLiBTZWFyY2ggU2VhcmNoIEdldCBZb3VyIEJ1c2luZXNzIENlcnRpZmllZCBZb3UgYXJlIGhlcmUgSG9tZSDigLogZGVwYXJ0bWVudHMg4oC6IEdldCBZb3VyIEJ1c2luZXNzIENlcnRpZmllZCBMYXN0IHVwZGF0ZWQ6IDUvMzEvMjQgTGVhcm4gbW9yZSBhYm91dCBob3cgdG8gYXBwbHkgZm9yIGNlcnRpZmljYXRpb24gd2l0aCB0aGUgQ2l0eSdzIFN1cHBsaWVyIERpdmVyc2l0eSBQcm9ncmFtLiBzaG93IGhpZGUgT25saW5lIFN0ZXAgMSBCZWZvcmUgeW91IGFwcGx5IG9ubGluZSwgY2hvb3NlIHlvdXIgY2VydGlmaWNhdGlvbiBPdXIgbWlzc2lvbiBpcyB0byBjcmVhdGUgZXF1YWwgb3Bwb3J0dW5pdGllcyBmb3IgYnVzaW5lc3NlcyBvZiBhbGwga2luZHMgaW4gQm9zdG9uLiBBZnRlciB5b3VyIGJ1c2luZXNzIGlzIGNlcnRpZmllZCB3aXRoIG91ciBvZmZpY2UsIHdlJ2xsIGluY2x1ZGUgeW91IGluIGFueSBvdXRyZWFjaCBlZmZvcnRzIHdlIG1ha2UgZm9yIENpdHkgcHJvamVjdHMuICI7czoxNDoicGFnZUlkZW50aWZpZXIiO047fXM6OToiZXh0cmFJbmZvIjthOjE6e3M6MTQ6InJlbGV2YW5jZVNjb3JlIjtkOjAuMzt9fWk6NzthOjU6e3M6NToidGl0bGUiO3M6NDk6IkhvdyBUbyBUYWtlIFBhcnQgSW4gQSBGYXJtZXJzIE1hcmtldCB8IEJvc3Rvbi5nb3YiO3M6ODoiZG9jdW1lbnQiO3M6MTY5OiJwcm9qZWN0cy83MzgzMTMxNzI3ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9vZW9pLXBpbG90LWRhdGFzdG9yZV8xNzI2MjY1Nzk1OTEwL2JyYW5jaGVzLzAvZG9jdW1lbnRzLzBmYTg1OWVhYmM0MTFlN2Y1M2Y1ZWIzMzBmZWQyNGVlIjtzOjM6InVyaSI7czo3NToiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9mb29kLWFjY2Vzcy9ob3ctdGFrZS1wYXJ0LWZhcm1lcnMtbWFya2V0IjtzOjEzOiJjaHVua0NvbnRlbnRzIjthOjI6e3M6NzoiY29udGVudCI7czoxODU5OiLZgtivINmK2KTYr9mKINmH2LDYpyDYpdmE2Ykg2YbYtSDZhdiq2LHYrNmFINi62YrYsSDYr9mC2YrZgiDYjCDYo9mIINij2K7Yt9in2KEg2KPYrtix2Ykg2YHZiiDYp9mE2LXZiNixINmI2KfZhNmF2LjZh9ixINin2YTYudin2YUg2YTZhNi12YHYrdin2Kog2KfZhNmF2KrYsdis2YXYqS4g2YjZhdi5INiw2YTZgyDYjCDZitmF2YPZhtmDINin2YTYpdio2YTYp9i6INi52YYg2KrYsdis2YXYp9iqINi62YrYsSDYtdit2YrYrdipINij2Ygg2K/ZiNmGINin2YTZhdiz2KrZiNmJINin2YTZhdi32YTZiNioINmI2KfZhNmF2LPYp9mH2YXYqSDZgdmKINiq2LHYrNmF2KfYqiDYo9mB2LbZhCDYqNin2LPYqtiu2K/Yp9mFINin2YTYqtix2KzZhdipINmF2YYgR29vZ2xlLiDYo9mI2YTYp9mLINiMINmF2LHYsSDYp9mE2YXYp9mI2LMg2YHZiNmCINij2Yog2YbYtSDZitit2KrZiNmKINi52YTZiSDYrti32KMg2YjYp9mG2YLYsSDYudmE2YrZhy4g2YrYrNioINij2YYg2YrYuNmH2LEg2YXYsdio2Lkg2YXZhtio2KvZgi4g2KjYudivINiw2YTZgyDYjCDYp9mG2YLYsSDZgdmI2YIg4oCc2KfZhNmF2LPYp9mH2YXYqSDYqNiq2LHYrNmF2Kkg2KPZgdi22YTigJwuINin2YbZgtixINmG2YLYsdmL2Kcg2YXYstiv2YjYrNmL2Kcg2YHZiNmCINmF2YbYt9mC2Kkg2KfZhNmG2KfZgdiw2Kkg2KfZhNmF2YbYqNir2YLYqSDYp9mE2KrZiiDYqtmC2YjZhCDigJzYp9mG2YLYsSDZgdmI2YIg2YPZhNmF2Kkg2YTZhNit2LXZiNmEINi52YTZiSDYqtix2KzZhdin2Kog2KjYr9mK2YTYqSDYjCDYo9mIINin2YbZgtixINmG2YLYsdmL2Kcg2YXYstiv2YjYrNmL2Kcg2YTZhNiq2LnYr9mK2YQg2YXYqNin2LTYsdip4oCcLiDZgtmFINio2KXYrNix2KfYoSDYqti52K/ZitmE2KfYqtmDINmF2KjYp9i02LHYqSDYudmE2Ykg2KfZhNmG2LUg2KfZhNmF2YjYrNmI2K8g2YHZiiDZhdix2KjYuSDYp9mE2YbYtS4g2KPYrtmK2LHZi9inINiMINin2LbYuti3INi52YTZiSDZhdiz2KfZh9mF2Kkg2YTZhNmF2LPYp9mH2YXYqSDYqNiq2LnYr9mK2YTYp9iq2YMg2KfZhNmF2YLYqtix2K3YqS4g2YrZhdmD2YYg2KfZhNi52KvZiNixINi52YTZiSDZhdiy2YrYryDZhdmGINin2YTZhdi52YTZiNmF2KfYqiDYrdmI2YQg2KfZhNmF2LPYp9mH2YXYqSDZgdmKINiq2LHYrNmF2KkgR29vZ2xlINmH2YbYpy4g2YrYsdis2Ykg2YXZhNin2K3YuNipINij2YYgRG9JVCDZhNinINiq2KrYrdmD2YUg2YHZiiDYp9mE2LnZhdmE2YrYqSDYp9mE2KrZiiDZitiq2YUg2YXZhiDYrtmE2KfZhNmH2Kcg2K/ZhdisINin2YTYqtix2KzZhdin2Kog2KfZhNmF2LPYp9mH2YXYqSDZgdmKINmF2KrYsdis2YUg2KfZhNmI2YrYqCDZhdmGIEdvb2dsZS4g2LLZhSDZhdiv2YrZhtipINio2YjYs9i32YYg2KjYqtit2LPZitmGINis2YjYr9ipINmI2KfYqtiz2KfYuSDYp9mE2YXYrdiq2YjZiSDZhdiq2LnYr9ivINin2YTZhNi62KfYqiDYudmE2Ykg2YXZiNmC2LnZhtinLiBTZWFyY2ggU2VhcmNoIEhvdyBUbyBUYWtlIFBhcnQgSW4gQSBGYXJtZXJzIE1hcmtldCBZb3UgYXJlIGhlcmUgSG9tZSDigLogZGVwYXJ0bWVudHMg4oC6IEZvb2QgSnVzdGljZSDigLogSG93IFRvIFRha2UgUGFydCBJbiBBIEZhcm1lcnMgTWFya2V0IExhc3QgdXBkYXRlZDogNC8yNS8yNCBMZWFybiBob3cgdG8gc3RhcnQgYSBmYXJtZXJzIG1hcmtldCwgb3Igam9pbiBhbiBleGlzdGluZyBvbmUgYXMgYSB2ZW5kb3IuIHNob3cgaGlkZSBBcyBhIG1hbmFnZXIgU3RlcCAxIEdldCB5b3VyIGluZm9ybWF0aW9uIHRvZ2V0aGVyIEl0J3MgYSBnb29kIGlkZWEgdG8gbGVhcm4gbW9yZSBhYm91dCB0aGUgcnVsZXMgYW5kIHJlZ3VsYXRpb25zIGZvciBydW5uaW5nIGEgZmFybWVycyBtYXJrZXQuICI7czoxNDoicGFnZUlkZW50aWZpZXIiO047fXM6OToiZXh0cmFJbmZvIjthOjE6e3M6MTQ6InJlbGV2YW5jZVNjb3JlIjtkOjAuMzt9fWk6ODthOjU6e3M6NToidGl0bGUiO3M6NDA6IkdldCBZb3VyIEJ1c2luZXNzIENlcnRpZmllZCB8IEJvc3Rvbi5nb3YiO3M6ODoiZG9jdW1lbnQiO3M6MTY5OiJwcm9qZWN0cy83MzgzMTMxNzI3ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZGF0YVN0b3Jlcy9vZW9pLXBpbG90LWRhdGFzdG9yZV8xNzI2MjY1Nzk1OTEwL2JyYW5jaGVzLzAvZG9jdW1lbnRzL2JkN2ZiNGU5YTU0NGQ4MjVkZDZiYjVhNDgyMjBhZGFmIjtzOjM6InVyaSI7czo5NToiaHR0cHM6Ly93d3cuYm9zdG9uLmdvdi9kZXBhcnRtZW50cy9zdXBwbGllci1hbmQtd29ya2ZvcmNlLWRpdmVyc2l0eS9nZXQteW91ci1idXNpbmVzcy1jZXJ0aWZpZWQiO3M6MTM6ImNodW5rQ29udGVudHMiO2E6Mjp7czo3OiJjb250ZW50IjtzOjI2Mjk6ItCeINC/0LXRgNC10LLQvtC00LDRhSDQvdCwIEJvc3Rvbi5nb3Yg0JTQtdC/0LDRgNGC0LDQvNC10L3RgiDQuNC90L3QvtCy0LDRhtC40Lkg0Lgg0YLQtdGF0L3QvtC70L7Qs9C40Lkg0LPQvtGA0L7QtNCwINCR0L7RgdGC0L7QvdCwICjigJxEb0lU4oCcKSDQv9GA0LXQtNC70LDQs9Cw0LXRgiDQv9C10YDQtdCy0L7QtNGLINC60L7QvdGC0LXQvdGC0LAg0L3QsCBCb3N0b24uZ292INGH0LXRgNC10Lcg0LLQtdCxLdC/0LXRgNC10LLQvtC00YfQuNC6IEdvb2dsZSBUcmFuc2xhdGUgKHRyYW5zbGF0ZS5nb29nbGUuY29tKS4g0J/QvtGB0LrQvtC70YzQutGDIEdvb2dsZSBUcmFuc2xhdGUg0Y/QstC70Y/QtdGC0YHRjyDQstC90LXRiNC90LjQvCDQstC10LEt0YHQsNC50YLQvtC8ICwgRG9JVCDQvdC1INC60L7QvdGC0YDQvtC70LjRgNGD0LXRgiDQutCw0YfQtdGB0YLQstC+INC40LvQuCDRgtC+0YfQvdC+0YHRgtGMINC/0LXRgNC10LLQtdC00LXQvdC90L7Qs9C+INC60L7QvdGC0LXQvdGC0LAuINCt0YLQviDQvNC+0LbQtdGCINC/0YDQuNCy0LXRgdGC0Lgg0Log0L3QtdGC0L7Rh9C90L7QvNGDINC/0LXRgNC10LLQtdC00LXQvdC90L7QvNGDINGC0LXQutGB0YLRgyDQuNC70Lgg0LTRgNGD0LPQuNC8INC+0YjQuNCx0LrQsNC8INCyINC40LfQvtCx0YDQsNC20LXQvdC40Y/RhSDQuCDQvtCx0YnQtdC80YMg0LLQuNC00YMg0L/QtdGA0LXQstC10LTQtdC90L3Ri9GFINGB0YLRgNCw0L3QuNGGLiDQntC00L3QsNC60L4g0LLRiyDQvNC+0LbQtdGC0LUg0YHQvtC+0LHRidCw0YLRjCDQviDQvdC10L/RgNCw0LLQuNC70YzQvdGL0YUg0LjQu9C4INC90LXQutCw0YfQtdGB0YLQstC10L3QvdGL0YUg0L/QtdGA0LXQstC+0LTQsNGFINC4INCy0L3QvtGB0LjRgtGMINCx0L7Qu9C10LUg0LrQsNGH0LXRgdGC0LLQtdC90L3Ri9C1INC/0LXRgNC10LLQvtC00Ysg0YEg0L/QvtC80L7RidGM0Y4gR29vZ2xlIFRyYW5zbGF0ZS4g0KHQvdCw0YfQsNC70LAg0L3QsNCy0LXQtNC40YLQtSDQutGD0YDRgdC+0YAg0LzRi9GI0Lgg0Lgg0YnQtdC70LrQvdC40YLQtSDQu9GO0LHQvtC5INGC0LXQutGB0YIsINGB0L7QtNC10YDQttCw0YnQuNC5INC+0YjQuNCx0LrRgy4g0JTQvtC70LbQvdC+INC/0L7Rj9Cy0LjRgtGM0YHRjyDQstGB0L/Qu9GL0LLQsNGO0YnQtdC1INC+0LrQvdC+LiDQlNCw0LvQtdC1INC90LDQttC80LjRgtC1IOKAnNCS0L3QtdGB0YLQuCDQu9GD0YfRiNC40Lkg0L/QtdGA0LXQstC+0LTigJwuINCU0LLQsNC20LTRiyDRidC10LvQutC90LjRgtC1INC+0LHQu9Cw0YHRgtGMINCy0YHQv9C70YvQstCw0Y7RidC10LPQviDQvtC60L3QsCDRgSDQvdCw0LTQv9C40YHRjNGOIOKAnNCp0LXQu9C60L3QuNGC0LUg0YHQu9C+0LLQviDQtNC70Y8g0LDQu9GM0YLQtdGA0L3QsNGC0LjQstC90YvRhSDQv9C10YDQtdCy0L7QtNC+0LIg0LjQu9C4INC00LLQsNC20LTRiyDRidC10LvQutC90LjRgtC1LCDRh9GC0L7QsdGLINC+0YLRgNC10LTQsNC60YLQuNGA0L7QstCw0YLRjCDQvdCw0L/RgNGP0LzRg9GOLuKAnCDQktC90LXRgdC40YLQtSDQuNC30LzQtdC90LXQvdC40Y8g0L3QtdC/0L7RgdGA0LXQtNGB0YLQstC10L3QvdC+INCyINGC0LXQutGB0YIg0LIg0YLQtdC60YHRgtC+0LLQvtC8INC/0L7Qu9C1LiDQndCw0LrQvtC90LXRhiwg0L3QsNC20LzQuNGC0LUgQ29udHJpYnV0ZSDQtNC70Y8g0LLQvdC10YHQtdC90LjRjyDQv9GA0LXQtNC70L7QttC10L3QvdGL0YUg0LLQsNC80Lgg0LjQt9C80LXQvdC10L3QuNC5LiDQlNC+0L/QvtC70L3QuNGC0LXQu9GM0L3Rg9GOINC40L3RhNC+0YDQvNCw0YbQuNGOINC+INGB0L7QtNC10LnRgdGC0LLQuNC4INC/0LXRgNC10LLQvtC00YfQuNC60YMgR29vZ2xlINC80L7QttC90L4g0L3QsNC50YLQuCDQt9C00LXRgdGMLiDQntCx0YDQsNGC0LjRgtC1INCy0L3QuNC80LDQvdC40LUsINGH0YLQviBEb0lUINC90LUg0LrQvtC90YLRgNC+0LvQuNGA0YPQtdGCINC/0YDQvtGG0LXRgdGBLCDRgSDQv9C+0LzQvtGJ0YzRjiDQutC+0YLQvtGA0L7Qs9C+INC/0LXRgNC10LLQvtC00YssINCy0LrQu9GO0YfQtdC90L3Ri9C1INCyINC/0LXRgNC10LLQvtC0LCDQstC60LvRjtGH0LDRjtGC0YHRjyDQsiDQstC10LEt0L/QtdGA0LXQstC+0LTRh9C40LogR29vZ2xlLiDQk9C+0YDQvtC0INCR0L7RgdGC0L7QvSDRgdGC0YDQtdC80LjRgtGB0Y8g0YPQu9GD0YfRiNC40YLRjCDQutCw0YfQtdGB0YLQstC+INC4INGI0LjRgNC+0YLRgyDQvNC90L7Qs9C+0Y/Qt9GL0YfQvdC+0LPQviDQutC+0L3RgtC10L3RgtCwINC90LAg0L3QsNGI0LXQvCDQstC10LEt0YHQsNC50YLQtS4gS3UgU2FhYnNhbiBUYXJqdW1pZGEgYm9nZ2EgQm9zdG9uLmdvdiBNYWdhYWxhZGEgQm9zdG9uIFdhYXhkYSBDdXNib29uZXlzaWludGEgaXlvIFRpa25vbG9qaXlhZGRhICjigJxEb0lU4oCcKSB3YXhheSBiaXhpc2FhIHRhcmp1bWFhZGRhIHdheGEga3VqaXJhIEJvc3Rvbi5nb3YgaXlhZGEgb28gbG9vIG1hcmluYXlvIHR1cmp1YmFhbmthIHdlYnNheWRoa2EgZWUgR29vZ2xlIFRyYW5zbGF0ZSAodHJhbnNsYXRlLmdvb2dsZS5jb20pLiAsIERvSVQgbWEgeHVrdW1hYW4gdGF5YWRhIGFtYSBzYXggYWhhYW50YSB3YXh5YWFiYWhhIGxhIHRhcmp1bWF5LiAiO3M6MTQ6InBhZ2VJZGVudGlmaWVyIjtOO31zOjk6ImV4dHJhSW5mbyI7YToxOntzOjE0OiJyZWxldmFuY2VTY29yZSI7ZDowLjM7fX1pOjk7YTo1OntzOjU6InRpdGxlIjtzOjU1OiJBcHBseSBmb3IgdGhlIDIwMjQgRm9vZCBDYXJ0IFBpbG90IFByb2dyYW0gfCBCb3N0b24uZ292IjtzOjg6ImRvY3VtZW50IjtzOjE2OToicHJvamVjdHMvNzM4MzEzMTcyNzg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2RhdGFTdG9yZXMvb2VvaS1waWxvdC1kYXRhc3RvcmVfMTcyNjI2NTc5NTkxMC9icmFuY2hlcy8wL2RvY3VtZW50cy9iNDZjNzUwOWJjZTdhMTUwOThiMTJkOGIzMWU0ZWYxNiI7czozOiJ1cmkiO3M6MTEyOiJodHRwczovL3d3dy5ib3N0b24uZ292L2dvdmVybm1lbnQvY2FiaW5ldHMvZWNvbm9taWMtb3Bwb3J0dW5pdHktYW5kLWluY2x1c2lvbi9hcHBseS0yMDI0LWZvb2QtY2FydC1waWxvdC1wcm9ncmFtIjtzOjEzOiJjaHVua0NvbnRlbnRzIjthOjI6e3M6NzoiY29udGVudCI7czoxMTU1OiJTdGVwIDMgUGljayB5b3VyIHZlbmRpbmcgbG9jYXRpb24gUGljayB5b3VyIHZlbmRpbmcgbG9jYXRpb24gYmFzZWQgb24gdGhlIGZvbGxvd2luZyBhdmFpbGFibGUgbG9jYXRpb25zOiBDaXR5IEhhbGwgUGxhemEgKERvd250b3duKSAxIENpdHkgSGFsbCBTcXVhcmUsIEJvc3RvbiwgTUEgMDIyMDMgTHVuY2gsIDExIGFtIHRvIDMgcG0gRGlubmVyLCA0IHBtIHRvIDggcG0gTWNraW0gQnJhbmNoLCBDZW50cmFsIExpYnJhcnkgKEJhY2sgQmF5KSA3MDAgQm95bHN0b24gU3QuIEJvc3RvbiwgTUEgMDIxMTYgTHVuY2gsIDExIGFtIHRvIDMgcG0gRGlubmVyLCA0IHBtIHRvIDggcG0gUGhpbGxpcHMgU3F1YXJlIChDaGluYXRvd24pIDEgSGFycmlzb24gQXZlLCBCb3N0b24sIE1BIDAyMTExIEx1bmNoLCAxMSBhbSB0byAzIHBtIERpbm5lciwgNCBwbSB0byA4IHBtIEFkYW1zIFN0cmVldCBCcmFuY2ggb2YgdGhlIEJvc3RvbiBQdWJsaWMgTGlicmFyeSAoRG9yY2hlc3RlcikgNjkwIEFkYW1zIFN0LCBEb3JjaGVzdGVyLCBNQSAwMjEyMiBMdW5jaCwgMTEgYW0gdG8gMyBwbSBEaW5uZXIsIDQgcG0gdG8gOCBwbSBNYXZlcmljayBTcXVhcmUgKEVhc3QgQm9zdG9uKSA2MyBNYXZlcmljayBTcXVhcmUsIEJvc3RvbiwgTUEgMDIxMjggTHVuY2gsIDExIGFtIHRvIDMgcG0gRGlubmVyLCA0IHBtIHRvIDggcG0gU3RlcCA0IFN0YXJ0IHZlbmRpbmcgT25jZSB5b3UgYXJlIGFwcHJvdmVkLCB5b3UgY2FuIHN0YXJ0IHZlbmRpbmchIEZvciBhbGwgcXVlc3Rpb25zIGFuZCBpbnF1aXJpZXMsIHBsZWFzZSBjb250YWN0OiBXZWxkb24gQm9kcmljayBNb2JpbGUgRW50ZXJwcmlzZSBNYW5hZ2VyIHdlbGRvbi5ib2RyaWNrQGJvc3Rvbi5nb3YgRm9vZCBDYXJ0IFBpbG90IFByb2dyYW0gTG9jYXRpb25zIEZvb2QgQ2FydCBCYWNrZ3JvdW5kIEluZm9ybWF0aW9uIEEgc3RyZWV0IGZvb2QgY2FydCBvcGVyYXRlcyBsaWtlIGEgbW9iaWxlIGtpdGNoZW4uIEl0IGlzIG5vdCBtb3Rvcml6ZWQuIEZvb2QgY2FydHMgYXJlIGxpbWl0ZWQgaW4gdGhlIHR5cGVzIG9mIGZvb2QgdGhleSBzZWxsLiBUaGV5IGNhbiBzZWxsIGNoaWNrZW4ga2Fib2JzLCBzYWxhZHMsIGZhbGFmZWwsIGJ1cnJpdG9zLCBldGMuIGlmIHRoZXkgaGF2ZSBoYW5kd2FzaGluZyBmYWNpbGl0aWVzLiAiO3M6MTQ6InBhZ2VJZGVudGlmaWVyIjtOO31zOjk6ImV4dHJhSW5mbyI7YToxOntzOjE0OiJyZWxldmFuY2VTY29yZSI7ZDowLjM7fX19fXM6OToiZXh0cmFJbmZvIjthOjc6e3M6MjI6InF1ZXJ5VW5kZXJzdGFuZGluZ0luZm8iO2E6MTp7czoyMzoicXVlcnlDbGFzc2lmaWNhdGlvbkluZm8iO2E6MTp7aTowO2E6MTp7czo0OiJ0eXBlIjtzOjE5OiJKQUlMX0JSRUFLSU5HX1FVRVJZIjt9fX1zOjEwOiJhbnN3ZXJOYW1lIjtzOjE2NjoicHJvamVjdHMvNzM4MzEzMTcyNzg4L2xvY2F0aW9ucy9nbG9iYWwvY29sbGVjdGlvbnMvZGVmYXVsdF9jb2xsZWN0aW9uL2VuZ2luZXMvb2VvaS1zZWFyY2gtcGlsb3RfMTcyNjI2NjEyNDM3Ni9zZXNzaW9ucy8xNzU3NjM5MDM3MDk3NzEzMDI2OS9hbnN3ZXJzLzM4MDE2NTA1OTEzMzcwNjgwMiI7czo1OiJzdGVwcyI7YToxOntpOjA7YToyOntzOjU6InN0YXRlIjtzOjk6IlNVQ0NFRURFRCI7czoxMToiZGVzY3JpcHRpb24iO3M6MzA6IlJlcGhyYXNlIHRoZSBxdWVyeSBhbmQgc2VhcmNoLiI7fX1zOjU6InN0YXRlIjtzOjk6IlNVQ0NFRURFRCI7czoxMDoiY3JlYXRlVGltZSI7czowOiIiO3M6MTI6ImNvbXBsZXRlVGltZSI7czowOiIiO3M6MjA6ImFuc3dlclNraXBwZWRSZWFzb25zIjtzOjA6IiI7fX1zOjExOiJzZXNzaW9uSW5mbyI7YTo2OntzOjQ6Im5hbWUiO3M6MTM5OiJwcm9qZWN0cy83MzgzMTMxNzI3ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZW5naW5lcy9vZW9pLXNlYXJjaC1waWxvdF8xNzI2MjY2MTI0Mzc2L3Nlc3Npb25zLzE3NTc2MzkwMzcwOTc3MTMwMjY5IjtzOjc6InF1ZXJ5SWQiO3M6Njk6InByb2plY3RzLzczODMxMzE3Mjc4OC9sb2NhdGlvbnMvZ2xvYmFsL3F1ZXN0aW9ucy8xNzU3NjM5MDM3MDk3NzEzMTY5OCI7czo1OiJzdGF0ZSI7czoxMToiSU5fUFJPR1JFU1MiO3M6NToidHVybnMiO2E6MTp7aTowO2E6Mjp7czo1OiJxdWVyeSI7YToyOntzOjc6InF1ZXJ5SWQiO3M6Njk6InByb2plY3RzLzczODMxMzE3Mjc4OC9sb2NhdGlvbnMvZ2xvYmFsL3F1ZXN0aW9ucy8xNzU3NjM5MDM3MDk3NzEzMTY5OCI7czo0OiJ0ZXh0IjtzOjQ5OiJIb3cgZG8gSSBiZWNvbWUgYSB2ZW5kb3Igd2l0aCB0aGUgQ2l0eSBvZiBCb3N0b24/Ijt9czo2OiJhbnN3ZXIiO3M6MTY2OiJwcm9qZWN0cy83MzgzMTMxNzI3ODgvbG9jYXRpb25zL2dsb2JhbC9jb2xsZWN0aW9ucy9kZWZhdWx0X2NvbGxlY3Rpb24vZW5naW5lcy9vZW9pLXNlYXJjaC1waWxvdF8xNzI2MjY2MTI0Mzc2L3Nlc3Npb25zLzE3NTc2MzkwMzcwOTc3MTMwMjY5L2Fuc3dlcnMvMzgwMTY1MDU5MTMzNzA2ODAyIjt9fXM6OToic3RhcnRUaW1lIjtzOjI3OiIyMDI0LTEwLTA0VDE5OjM5OjUxLjQyNjg0N1oiO3M6NzoiZW5kVGltZSI7czoyNzoiMjAyNC0xMC0wNFQxOTozOTo1MS40MjY4NDdaIjt9fX0="); + return unserialize($a); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsBase.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsBase.php new file mode 100644 index 0000000000..84f536bc44 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsBase.php @@ -0,0 +1,75 @@ +object)) { + if (is_array($value)){ + $this->object[$key] = array_merge($this->object[$key], $value); + } + else { + $this->object[$key] = $value; + } + } + return $this; + } + + /** + * @inheritDoc + */ + public function get(string $key): NULL|int|bool|string|array|GcDiscoveryEngineObjectsInterface { + return $this->object[$key] ?? NULL; + } + + /** + * @inheritDoc + */ + public function toArray(): array { + return $this->trimArray($this->object) ?: []; + } + + /** + * @inheritDoc + */ + public function toJson(): string { + return json_encode($this->toArray()); + } + + /** + * Removes elements in the array which been set to null. + * + * @param $array + * + * @return NULL|array + */ + private function trimArray($array): NULL|array { + $output = []; + foreach ($array as $key => $value) { + if ($value != NULL) { + if (is_object($value)) { + $newval = $this->trimArray($value->toArray()); + if ($newval !== NULL) { + $output[$key] = $newval; + } + } + else { + $output[$key] = $value; + } + } + } + return empty($output) ? NULL : $output; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsInterface.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsInterface.php new file mode 100644 index 0000000000..908e278de2 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsInterface.php @@ -0,0 +1,42 @@ +errors); + } + + /** + * @inheritDoc + */ + public function getErrors(): array { + return $this->errors; + } + + /** + * @inheritDoc + */ + public function setError(string $error): void { + $this->errors[] = $error; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsResponseInterface.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsResponseInterface.php new file mode 100644 index 0000000000..f0385f36b5 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/GcDiscoveryEngineObjectsResponseInterface.php @@ -0,0 +1,42 @@ + NULL, + "session" => NULL, + "answerQueryToken" => NULL, + ]; + + public function __construct(array $response) { + $this->object = $response; + } + + /** + * @inheritDoc + * @return bool + */ + public function validate(): bool { + return TRUE; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/SearchResponse.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/SearchResponse.php new file mode 100644 index 0000000000..f667367f9d --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/SearchResponse.php @@ -0,0 +1,39 @@ +object = $response; + } + + /** + * @inheritDoc + * @return bool + */ + public function validate(): bool { + return TRUE; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/AnswerGenerationSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/AnswerGenerationSpec.php new file mode 100644 index 0000000000..247a435099 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/AnswerGenerationSpec.php @@ -0,0 +1,35 @@ +object = [ + "modelSpec" => NULL, // object \Drupal\bos_google_cloud\modelSpec + "promptSpec" => NULL, // object \Drupal\bos_google_cloud\modelPromptSpec + "includeCitations" => NULL, // boolean + "answerLanguageCode" => NULL, // string + "ignoreAdversarialQuery" => NULL, // boolean + "ignoreNonAnswerSeekingQuery" => NULL, // boolean + "ignoreJailBreakingQuery" => NULL, // boolean + "ignoreLowRelevantContent" => NULL, // boolean + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/ModelSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/ModelSpec.php new file mode 100644 index 0000000000..1163a975fe --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/ModelSpec.php @@ -0,0 +1,29 @@ +object = [ + "modelVersion" => NULL, // string One of "stable" or "preview" + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/PromptSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/PromptSpec.php new file mode 100644 index 0000000000..aeac4353c9 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/answerGenerationSpec/PromptSpec.php @@ -0,0 +1,29 @@ +object = [ + "preamble" => NULL, // string + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ChunkSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ChunkSpec.php new file mode 100644 index 0000000000..4bea0020d3 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ChunkSpec.php @@ -0,0 +1,31 @@ +object = [ + "numPreviousChunks" => NULL, // int Max 3 default 0 + "numNextChunks" => NULL, // int Max 3 default 0 + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ContentSearchSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ContentSearchSpec.php new file mode 100644 index 0000000000..a8d81ce3a7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ContentSearchSpec.php @@ -0,0 +1,33 @@ +object = [ + "snippetSpec" => NULL, // object \Drupal\bos_google_cloud\snippetSpec + "summarySpec" => NULL, // object \Drupal\bos_google_cloud\summarySpec + "extractiveContentSpec" => NULL, // object \Drupal\bos_google_cloud\extractiveSummarySpec + "searchResultMode" => NULL, // string - SEARCH_RESULT_MODE_UNSPECIFIED or DOCUMENTS or CHUNKS + "chunkSpec" => NULL, // object \Drupal\bos_google_cloud\chunkSpec + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ExtractiveContentSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ExtractiveContentSpec.php new file mode 100644 index 0000000000..782c8af08f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ExtractiveContentSpec.php @@ -0,0 +1,34 @@ +object = [ + "maxExtractiveAnswerCount" => NULL, // int + "maxExtractiveSegmentCount" => NULL, // int + "returnExtractiveSegmentScore" => NULL, // boolean + "numPreviousSegments" => NULL, // int + "numNextSegments" => NULL, // int + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ModelPromptSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ModelPromptSpec.php new file mode 100644 index 0000000000..6b0caccea7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ModelPromptSpec.php @@ -0,0 +1,29 @@ +object = [ + "preamble" => NULL, // string + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ModelSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ModelSpec.php new file mode 100644 index 0000000000..5b65fcba3a --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/ModelSpec.php @@ -0,0 +1,29 @@ +object = [ + "version" => NULL, // string One of "stable" or "preview" + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/SnippetSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/SnippetSpec.php new file mode 100644 index 0000000000..7d9220866e --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/SnippetSpec.php @@ -0,0 +1,31 @@ +object = [ + "maxSnippetCount" => NULL, // int + "referenceOnly" => NULL, // boolean + "returnSnippet" => NULL, // boolean + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/SummarySpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/SummarySpec.php new file mode 100644 index 0000000000..3233b8d5d7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/contentSearchSpec/SummarySpec.php @@ -0,0 +1,39 @@ +object = [ + "summaryResultCount" => NULL, // int + "includeCitations" => NULL, // boolean + "ignoreAdversarialQuery" => NULL, // boolean + "ignoreNonSummarySeekingQuery" => NULL, // boolean + "ignoreLowRelevantContent" => NULL, // boolean + "ignoreJailBreakingQuery" => NULL, // boolean + "modelPromptSpec" => NULL, // object \Drupal\bos_google_cloud\modelPromptSpec + "languageCode" => NULL, // string + "modelSpec" => NULL, // object \Drupal\bos_google_cloud\modelSpec + "useSemanticChunks" => NULL, // boolean + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/customFineTuningSpec/CustomFineTuningSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/customFineTuningSpec/CustomFineTuningSpec.php new file mode 100644 index 0000000000..ae4e608f04 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/customFineTuningSpec/CustomFineTuningSpec.php @@ -0,0 +1,28 @@ +object = [ + "enableSearchAdaptor" => NULL, // bool + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/personalizationSpec/PersonalizationSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/personalizationSpec/PersonalizationSpec.php new file mode 100644 index 0000000000..daf1d78290 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/personalizationSpec/PersonalizationSpec.php @@ -0,0 +1,29 @@ +object = [ + "mode" => NULL, // string - MODE_UNSPECIFIED or SUGGESTION_ONLY or AUTO + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/ConversationContext.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/ConversationContext.php new file mode 100644 index 0000000000..7247cb4118 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/ConversationContext.php @@ -0,0 +1,30 @@ +object = [ + "contextDocuments" => NULL, // array of strings + "activeDocument" => NULL, // string + ]; + $this->object = array_merge($this->object, $settings); + + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/Converse.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/Converse.php new file mode 100644 index 0000000000..4f020b045b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/Converse.php @@ -0,0 +1,38 @@ +object = [ + "query" => NULL, + "servingConfig" => NULL, + "conversation" => NULL, + "safeSearch" => NULL, // control the level of explicit content that the system can display in the results. This is similar to the feature used in Google Search, where you can modify your settings to filter explicit content, such as nudity, violence, and other adult content, from the search results. + "userLabels" => NULL, + "summarySpec" => NULL, + "filter" => NULL, + "boostSpec" => NULL, + ]; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/TextInput.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/TextInput.php new file mode 100644 index 0000000000..755e302889 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/conversations/TextInput.php @@ -0,0 +1,29 @@ +object = [ + "input" => NULL, // string + "context" => NULL, // object ConversationContext + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/sessions/Answer.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/sessions/Answer.php new file mode 100644 index 0000000000..c2c4d94119 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/sessions/Answer.php @@ -0,0 +1,39 @@ + NULL, + "session" => NULL, + "answerQueryToken" => NULL, + ]; + + public function __construct(array $response) { + $this->object = $response; + } + + /** + * @inheritDoc + * @return bool + */ + public function validate(): bool { + return TRUE; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/sessions/Query.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/sessions/Query.php new file mode 100644 index 0000000000..d1b40b668b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/dataStores/sessions/Query.php @@ -0,0 +1,29 @@ +object = [ + "queryId" => NULL, // string + "text" => NULL, // string + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/engines/servingConfigs/Answer.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/engines/servingConfigs/Answer.php new file mode 100644 index 0000000000..8106e53210 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/engines/servingConfigs/Answer.php @@ -0,0 +1,82 @@ +object = [ + "query" => NULL, // object Query + "session" => NULL, // string + "safetyspec" => NULL, // object SafetySpec + "relatedQuestionsSpec" => NULL, // object RelatedQuestionsSpec + "answerGenerationSpec" => NULL, // object AnswerGenerationSpec + "searchSpec" => NULL, // object SearchSpec + "queryUnderstandingSpec" => NULL, // object QueryUnderstandingSpec + "asynchronousMode" => NULL, // boolean + "usePseudoId" => NULL, // string + "userLabels" => NULL, // array of strings + ]; + } + + /** + * Set the session for a given project, engine, and other optional parameters. + * + * @param string $project_id The ID of the project. + * @param string $engine The engine name. + * @param string $location The location (default is "global"). + * @param string $collection The collection name (default is "default_collection"). + * @param string $session_id The session ID (default is "-"). + * + * @return string The formatted session string. + */ + public function setSession(string $project_id, string $engine, + string $session_id = "-", + string $location = "global", + string $collection = "default_collection", + ): GcDiscoveryEngineObjectsInterface { + $this->object["session"] = "projects/$project_id/locations/$location/collections/$collection/engines/$engine/sessions/$session_id"; + return $this; + } + + /** + * Sets the query details for the object. + * + * @param string $text The text of the query. + * @param string $project_id The project ID associated with the query. + * @param string $query_id The optional query ID, defaults to "-". + * @param string $location The location for the query, defaults to "global". + * + * @return GcDiscoveryEngineObjectsInterface The current instance of the object. + */ + public function setQuery(string $text, string $query_id , string $project_id, + string $location = "global", + ): GcDiscoveryEngineObjectsInterface { + $this->object["query"] = new Query([ + "text" => $text, + "queryId" => "projects/$project_id/locations/$location/questions/$query_id" + ]); + return $this; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/engines/servingConfigs/Search.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/engines/servingConfigs/Search.php new file mode 100644 index 0000000000..3cb84e7fbf --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/collections/engines/servingConfigs/Search.php @@ -0,0 +1,112 @@ +object = [ + "branch" => NULL, // string + "query" => NULL, // string + "imageQuery" => NULL, // Drupal\bos_google_cloud\imageQuery + "pageSize" => NULL, // int + "pageToken" => NULL, // string + "offset" => NULL, // int + "dataStoreSpecs" => NULL, // array of Drupal\bos_google_cloud\dataStoreSpec + "filter" => NULL, // string + "canonicalFilter" => NULL, // string + "orderBy" => NULL, // string + "userInfo" => NULL, // Drupal\bos_google_cloud\userInfo + "languageCode" => NULL, // string + "regionCode" => NULL, // string + "facetSpecs" => NULL, // array of Drupal\bos_google_cloud\facetSpec + "boostSpec" => NULL, // Drupal\bos_google_cloud\boostSpec + "params" => NULL, + "queryExpansionSpec" => NULL, // Drupal\bos_google_cloud\queryExpansionSpec + "spellCorrectionSpec" => NULL, // Drupal\bos_google_cloud\spellCorrectionSpec + "usePseudoId" => NULL, // string + + // Must be NULL if Session provided + "contentSearchSpec" => NULL, // Drupal\bos_google_cloud\contentSearchSpec + + "embeddingSpec" => NULL, // Drupal\bos_google_cloud\embeddingSpec + "rankingExpression" => NULL, // string + "safeSearch" => NULL, // bool + "userLabels" => NULL, + "naturalLanguageQueryUnderstandingSpec" => NULL, // Drupal\bos_google_cloud\naturalLanguageQueryUnderstandingSpec + "searchAsYouTypeSpec" => NULL, // Drupal\bos_google_cloud\searchAsYouTypeSpec + "customFineTuningSpec" => NULL, // Drupal\bos_google_cloud\customFineTuningSpec + + // Must be null if contentSearchSpec.summarySpec provided + "session" => NULL, // string + + "sessionSpec" => NULL, // Drupal\bos_google_cloud\sessionSpec + "relevanceThreshold" => NULL, // LOWEST, LOW, MEDIUM, HIGH, RELEVANCE_THRESHOLD_UNSPECIFIED + "personalizationSpec" => NULL, // Drupal\bos_google_cloud\personalizationSpec + ]; + } + + /** + * Set the session for a given project, engine, and other optional parameters. + * + * @param string $project_id The ID of the project. + * @param string $engine The engine name. + * @param string $location The location (default is "global"). + * @param string $collection The collection name (default is "default_collection"). + * @param string $session_id The conversation ID (default is "-"). + * + * @return string The formatted session string. + */ + public function setSession(string $project_id, string $engine, + string $session_id = "-", + string $location = "global", + string $collection = "default_collection", + ): GcDiscoveryEngineObjectsInterface { + if (empty($session_id)){ + $session_id = "-"; + } + $this->object["session"] = "projects/$project_id/locations/$location/collections/$collection/engines/$engine/sessions/$session_id"; + return $this; + } + + /** + * Sets the query details for the object. + * + * @param string $text The text of the query. + * @param string $project_id The project ID associated with the query. + * @param string $query_id The optional query ID, defaults to "-". + * @param string $location The location for the query, defaults to "global". + * + * @return GcDiscoveryEngineObjectsInterface The current instance of the object. + */ + public function setQuery(string $query_id , string $project_id, + string $location = "global", + int $searchResultPersistenceCount = 5 + ): GcDiscoveryEngineObjectsInterface { + $this->object["sessionSpec"] = new SessionSpec([ + "queryId" => "projects/$project_id/locations/$location/questions/$query_id", + "searchResultPersistenceCount" => $searchResultPersistenceCount, + ]); + return $this; + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/BoostControlSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/BoostControlSpec.php new file mode 100644 index 0000000000..005966e318 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/BoostControlSpec.php @@ -0,0 +1,33 @@ +object = [ + "fieldName" => NULL, // string + "attributeType" => NULL, // string - ATTRIBUTE_TYPE_UNSPECIFIED or NUMERICAL or FRESHNESS + "interpolationType" => NULL, // string - INTERPOLATION_TYPE_UNSPECIFIED or LINEAR + "controlPoints" => NULL, // array of \Drupal\bos_google_cloud\controlPoint + ]; + $this->object = array_merge($this->object, $settings); + } + +} + + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/BoostSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/BoostSpec.php new file mode 100644 index 0000000000..e96fb05be4 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/BoostSpec.php @@ -0,0 +1,30 @@ +object = [ + "conditionBoostSpecs" => NULL, // array of \Drupal\bos_google_cloud\conditionBoostSpec + ]; + $this->object = array_merge($this->object, $settings); + } + +} + + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ConditionBoostSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ConditionBoostSpec.php new file mode 100644 index 0000000000..4e7b6b6a9f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ConditionBoostSpec.php @@ -0,0 +1,32 @@ +object = [ + "condition" => NULL, // string + "boost" => NULL, // number + "boostControlSpec" => NULL, // array of \Drupal\bos_google_cloud\boostControlSpec + ]; + $this->object = array_merge($this->object, $settings); + } + +} + + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ControlPoint.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ControlPoint.php new file mode 100644 index 0000000000..848b7fd57e --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ControlPoint.php @@ -0,0 +1,30 @@ +object = [ + "attributeValue" => NULL, // string + "boostAmount" => NULL, // number + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/DataStoreSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/DataStoreSpec.php new file mode 100644 index 0000000000..59a0fd7ffe --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/DataStoreSpec.php @@ -0,0 +1,31 @@ +object = [ + "dataStore" => NULL, + "filter" => NULL, + ]; + $this->object = array_merge($this->object, $settings); + + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/EmbeddingSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/EmbeddingSpec.php new file mode 100644 index 0000000000..3a06d41c71 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/EmbeddingSpec.php @@ -0,0 +1,29 @@ +object = [ + "embeddingVectors" => NULL, //array of \Drupal\bos_google_cloud\embeddingVector + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/EmbeddingVector.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/EmbeddingVector.php new file mode 100644 index 0000000000..d29f67f2dc --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/EmbeddingVector.php @@ -0,0 +1,30 @@ +object = [ + "fieldPath" => NULL, // string + "vector" => NULL, // array of numbers + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/FacetKey.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/FacetKey.php new file mode 100644 index 0000000000..5f46f66161 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/FacetKey.php @@ -0,0 +1,35 @@ +object = [ + "key" => NULL, // string + "intervals" => NULL, // array of \Drupal\bos_google_cloud\interval objects + "restrictedValues" => NULL, // array of strings + "prefixes" => NULL, // array of strings + "contains" => NULL, // array of strings + "caseInsensitive" => NULL, // boolean + "orderBy" => NULL, // string + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/FacetSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/FacetSpec.php new file mode 100644 index 0000000000..4183dd3893 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/FacetSpec.php @@ -0,0 +1,32 @@ +object = [ + "facetKey" => NULL, // object \Drupal\bos_google_cloud\facetKey + "limit" => NULL, // int + "excludedFilterKeys" => NULL, // array + "enableDynamicPosition" => NULL // bool + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ImageQuery.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ImageQuery.php new file mode 100644 index 0000000000..2d87e94f17 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/ImageQuery.php @@ -0,0 +1,29 @@ +object = [ + "imageBytes" => NULL, + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/MaxInterval.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/MaxInterval.php new file mode 100644 index 0000000000..9ef9c40cb4 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/MaxInterval.php @@ -0,0 +1,31 @@ +object = [ + "maximum" => NULL, // number + "exclusiveMaximum" => NULL, // number + ]; + $this->object = array_merge($this->object, $settings); + } + +} + + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/MinInterval.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/MinInterval.php new file mode 100644 index 0000000000..a3ab99fd34 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/MinInterval.php @@ -0,0 +1,30 @@ +object = [ + "minimum" => NULL, // number + "exclusiveMinimum" => NULL, // number + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/NaturalLanguageQueryUnderstandingSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/NaturalLanguageQueryUnderstandingSpec.php new file mode 100644 index 0000000000..063b998e07 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/NaturalLanguageQueryUnderstandingSpec.php @@ -0,0 +1,30 @@ +object = [ + "filterExtractionCondition" => NULL, // string CONDITION_UNSPECIFIED or DISABLED or ENABLED + "geoSearchQueryDetectionFieldNames" => NULL, // array of strings + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/QueryExpansionSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/QueryExpansionSpec.php new file mode 100644 index 0000000000..86d42e1448 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/QueryExpansionSpec.php @@ -0,0 +1,31 @@ +object = [ + "condition" => NULL, // string - CONDITION_UNSPECIFIED or DISABLED or AUTO + "pinUnexpandedResults" => NULL, // bool + ]; + $this->object = array_merge($this->object, $settings); + } + +} + + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SearchAsYouTypeSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SearchAsYouTypeSpec.php new file mode 100644 index 0000000000..4d8e06071e --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SearchAsYouTypeSpec.php @@ -0,0 +1,29 @@ +object = [ + "condition" => NULL, // string CONDITION_UNSPECIFIED or DISABLED or ENABLED + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SessionSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SessionSpec.php new file mode 100644 index 0000000000..f925df99c8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SessionSpec.php @@ -0,0 +1,29 @@ +object = [ + "queryId" => NULL, // string + "searchResultPersistenceCount" => NULL, // int + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SpellCorrectionSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SpellCorrectionSpec.php new file mode 100644 index 0000000000..b7194faeb7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/projects/locations/evaluations/SpellCorrectionSpec.php @@ -0,0 +1,30 @@ +object = [ + "mode" => NULL, // string - MODE_UNSPECIFIED or SUGGESTION_ONLY or AUTO + "filter" => NULL, + ]; + $this->object = array_merge($this->object, $settings); + } + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryClassificationSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryClassificationSpec.php new file mode 100644 index 0000000000..07995270e3 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryClassificationSpec.php @@ -0,0 +1,28 @@ +object = [ + "types" => NULL, // array of TYPE_UNSPECIFIED, ADVERSARIAL_QUERY, NON_ANSWER_SEEKING_QUERY, JAIL_BREAKING_QUERY, NON_ANSWER_SEEKING_QUERY_V2 + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryRephraserSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryRephraserSpec.php new file mode 100644 index 0000000000..04d490235f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryRephraserSpec.php @@ -0,0 +1,28 @@ +object = [ + "enable" => NULL, // boolean + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryUnderstandingSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryUnderstandingSpec.php new file mode 100644 index 0000000000..f2be8a5dbd --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/queryUnderstandingSpec/QueryUnderstandingSpec.php @@ -0,0 +1,28 @@ +object = [ + "enable" => NULL, // boolean + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/relatedQuestionsSpec/RelatedQuestionsSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/relatedQuestionsSpec/RelatedQuestionsSpec.php new file mode 100644 index 0000000000..52154c9149 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/relatedQuestionsSpec/RelatedQuestionsSpec.php @@ -0,0 +1,28 @@ +object = [ + "enable" => NULL, // boolean + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/safetySpec/SafetySpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/safetySpec/SafetySpec.php new file mode 100644 index 0000000000..f1436ae7e9 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/safetySpec/SafetySpec.php @@ -0,0 +1,28 @@ +object = [ + "enable" => NULL, // boolean + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ChunkInfo.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ChunkInfo.php new file mode 100644 index 0000000000..bc13b08fd9 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ChunkInfo.php @@ -0,0 +1,30 @@ +object = [ + "chunk" => NULL, // string, + "content" => NULL, // string, + "documentMetadata>" => NULL, // array of object (DocumentMetadata) + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/DocumentContext.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/DocumentContext.php new file mode 100644 index 0000000000..1a3241d8b8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/DocumentContext.php @@ -0,0 +1,29 @@ +object = [ + "pageIdentifier" => NULL, // string, + "content" => NULL, // string, + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/DocumentMetadata.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/DocumentMetadata.php new file mode 100644 index 0000000000..1de1bcecca --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/DocumentMetadata.php @@ -0,0 +1,29 @@ +object = [ + "uri" => NULL, // string, + "title" => NULL, // string, + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ExtractiveAnswer.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ExtractiveAnswer.php new file mode 100644 index 0000000000..8be9e7ec66 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ExtractiveAnswer.php @@ -0,0 +1,30 @@ +object = [ + "pageIdentifier" => NULL, // string, + "content" => NULL, // string, + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ExtractiveSegment.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ExtractiveSegment.php new file mode 100644 index 0000000000..a479ad7b78 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/ExtractiveSegment.php @@ -0,0 +1,30 @@ +object = [ + "pageIdentifier" => NULL, // string, + "content" => NULL, // string, + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchParams.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchParams.php new file mode 100644 index 0000000000..5e98778576 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchParams.php @@ -0,0 +1,35 @@ +object = [ + "maxReturnResults" => NULL, // int + "filter" => NULL, // string + "boostSpec" => NULL, // object Apis/v1alpha/projects/locations/evaluations/BoostSpec + "orderBy" => NULL, // string + "searchResultMode" => NULL, // string One of SEARCH_RESULT_MODE_UNSPECIFIED, DOCUMENTS or CHUNKS + "customFineTuningSpec" => NULL, // object Apis/v1alpha/customFineTuningSpec/CustomFineTuningSpec + "dataStoreSpecs" => NULL, // array of object Apis/v1alpha/projects/locations/evaluationsDataStoreSpec + "naturalLanguageQueryUnderstandingSpec"=> NULL, // object Apis/v1alpha/projects/locations/evaluations/NaturalLanguageQueryUnderstandingSpec + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchResult.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchResult.php new file mode 100644 index 0000000000..54c31c825b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchResult.php @@ -0,0 +1,29 @@ +object = [ + "unstructuredDocumentInfo" => NULL, // object unstructuredDocumentInfo + "chunkInfo" => NULL, // object chunkInfo + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchResultList.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchResultList.php new file mode 100644 index 0000000000..d1e272a47d --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchResultList.php @@ -0,0 +1,28 @@ +object = [ + "searchResult" => NULL, // object SearchResult + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchSpec.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchSpec.php new file mode 100644 index 0000000000..e2084bdeed --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/SearchSpec.php @@ -0,0 +1,29 @@ +object = [ + "searchParams" => NULL, // object SearchParams + "searchResultList" => NULL, // object SearchResultsList + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/UnstructuredDocumentInfo.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/UnstructuredDocumentInfo.php new file mode 100644 index 0000000000..86618ee30d --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/searchSpec/UnstructuredDocumentInfo.php @@ -0,0 +1,33 @@ +object = [ + "document" => NULL, // string, + "uri" => NULL, // string, + "title" => NULL, // string, + "documentContexts" => NULL, // array of object (DocumentContext) + "extractiveSegments"=> NULL, // array of object (ExtractiveSegment) + "extractiveAnswers"=> NULL, // array of object (ExtractiveAnswer) + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/userInfo/UserInfo.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/userInfo/UserInfo.php new file mode 100644 index 0000000000..1f3936a8c8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Apis/v1alpha/userInfo/UserInfo.php @@ -0,0 +1,29 @@ +object = [ + "userId" => NULL, + "filter" => NULL, + ]; + $this->object = array_merge($this->object, $settings); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Controller/GcApiEndpoint.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Controller/GcApiEndpoint.php index 8dbd417f92..84b2ac39d0 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Controller/GcApiEndpoint.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Controller/GcApiEndpoint.php @@ -78,7 +78,7 @@ private function search(array $payload): CacheableJsonResponse { * AI Conversation of boston.gov endpoint. * * Requires an array with "text" and optionally "prompt" in its JSON payload, - * and possibly a conversation_id to continue a previous conversation. + * and possibly a session_id to continue a previous conversation. * * @param array $payload * @@ -86,7 +86,7 @@ private function search(array $payload): CacheableJsonResponse { */ private function converse(array $payload): CacheableJsonResponse { - if ($payload["conversation_id"]) { + if ($payload["session_id"]) { $payload["allow_conversation"] = TRUE; } @@ -99,7 +99,7 @@ private function converse(array $payload): CacheableJsonResponse { $response = $converse->response(); if ($payload["allow_conversation"]) { - return $this->output($result . "\r\nid: " . $response["conversation_id"], $response["http_code"]); + return $this->output($result . "\r\nid: " . $response["session_id"], $response["http_code"]); } return $this->output($result, $response["http_code"]); diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Controller/GcApiEndpointTester.http b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Controller/GcApiEndpointTester.http index d46629fb9c..edc043fea1 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Controller/GcApiEndpointTester.http +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Controller/GcApiEndpointTester.http @@ -70,7 +70,7 @@ Cookie: XDEBUG_SESSION=PHPSTORM { "text": "is there one in Allston", "prompt": "default", - "conversation_id": "12303549218688245999" + "session_id": "12303549218688245999" } ### GenAI - Summarize (20 Words) diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Form/PromptTesterForm.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Form/PromptTesterForm.php index de24b5f7e6..ac8530bdbe 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Form/PromptTesterForm.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Form/PromptTesterForm.php @@ -4,7 +4,9 @@ use Drupal\bos_google_cloud\GcGenerationPrompt; use Drupal\bos_google_cloud\Services\GcCacheAI; +use Drupal\bos_google_cloud\Services\GcConversation; use Drupal\bos_google_cloud\Services\GcTextSummarizer; +use Drupal\bos_google_cloud\Services\GcTranslation; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Markup; @@ -31,6 +33,7 @@ class PromptTesterForm extends FormBase { "summarizer" => "Summarize", "rewriter" => "Rewrite", "translation" => "Translate", + "conversation" => 'Conversation', ]; /** @@ -67,6 +70,7 @@ public function buildForm(array $form, FormStateInterface $form_state): array { 'rewriter' => 'Rewrite', 'summarizer' => 'Summarize', 'translation' => 'Translate', + 'conversation' => 'Conversation', ], '#default_value' => $type??"select", '#ajax' => [ @@ -170,12 +174,31 @@ public function ajaxCallbackProcess(array $form, FormStateInterface $form_state) case "translation": /** - * @var \Drupal\bos_google_cloud\Services\GcTranslation $processor + * @var GcTranslation $processor */ $processor = \Drupal::service("bos_google_cloud.GcTranslate"); $processor->setExpiry(GcCacheAI::CACHE_EXPIRY_NO_CACHE); $result = $processor->execute(["text"=>$values["text"], "lang"=>$values["prompt"], "prompt"=>"default"]); break; + + case "conversation": + /** + * @var GcConversation $processor + */ + $processor = \Drupal::service("bos_google_cloud.GcConversation"); + $result = $processor->execute([ + "text"=>$values["text"], + "lang"=>$values["prompt"], + "prompt"=>$values["prompt"], + "extra_prompt" => 'If you cannot understand the question or the question cannot be answered, respond with the text "No Results"', + "metadata" => 1, + "num_results" => 5, + "include_citations" => 1, + "safe_search" => 1, + "semantic_chunks" => 1, + ]); +// $result = $result->render(); + break; } $form["PromptTesterForm"]["testdata"] = $this->ajaxCallbackPrompts($form, $form_state); @@ -183,61 +206,99 @@ public function ajaxCallbackProcess(array $form, FormStateInterface $form_state) $result = $result ?? "No result from google_cloud (Vertex)"; $response = $processor->response(); $request = $processor->request(); - $fullprompt = $request["body"]["contents"][0]["parts"]; - $prompt = array_pop($fullprompt)["text"]; - $fullprompt = implode("
- ", array_column($fullprompt, "text")); - - $ai_engine = $response["ai_engine"] ?? "gemini-pro"; - - $requestSafety = []; - foreach($request["body"]["safetySettings"] as $safe) { - $requestSafety[] = "{$safe["category"]} threshold: {$safe["threshold"]}"; - } - $requestSafety = implode("
", $requestSafety); - - $responseSafety = []; - foreach($response[$ai_engine]["ratings"] as $safety) { - if (!empty($safety["category"])) { - $responseSafety[] = "{$safety["category"]} probability: {$safety["probabilityScore"]} ({$safety["probability"]})"; - $responseSafety[] = "{$safety["category"]} severity: {$safety["severityScore"]} ({$safety["severity"]})"; - } - else { - $responseSafety[] = "OVERALL probability: {$safety["probabilityScore"]} ({$safety["probability"]})"; - $responseSafety[] = "OVERALL severity: {$safety["severityScore"]} ({$safety["severity"]})"; - } - } - $responseSafety = implode("
", $responseSafety); + switch($type) { + case 'conversation': + $fullprompt = $request["body"]["summarySpec"]["modelPromptSpec"]["preamble"]; + $form["PromptTesterForm"]["testdata"]["testresults"] = [ + "#type" => "container", + '#attributes' => ['id' => ['edit-testresults']], + "output" => [ + "#type" => "fieldset", + "#title" => Markup::create("$type_label results"), + "outputtext" => [ + "#markup" => Markup::create($result) + ], + "citations" => [ + '#type' => 'details', + '#title' => 'Citations', + "items" => $this->formatCitations($processor) + ], + "search_results" => [ + '#type' => 'details', + '#title' => 'Search Results', + "items" => $this->formatResults($processor) + ], + "moreinfo" => [ + "#type" => "details", + "#title" => "More Information", + "info" => [ + "#markup" => " + + + +
Action$type_label
Prompt ID{$values['prompt']}
Full Prompt$fullprompt
", + ] + ] + ] + ]; - $responseMetadata = []; - if ($response[$ai_engine]["usageMetadata"]) { - $responseMetadata[] = "Prompt Token Count: " . $response[$ai_engine]["usageMetadata"]["promptTokenCount"] ?? "N/A"; - $responseMetadata[] = "Candidates Token Count: " . $response[$ai_engine]["usageMetadata"]["candidatesTokenCount"] ?? "N/A"; - $responseMetadata[] = "Total Token Count: " . $response[$ai_engine]["usageMetadata"]["totalTokenCount"] ?? "N/A"; - } - $responseMetadata[] = "Analysis time: " . (intval(($response["elapsedTime"] ?? 0) * 10000) / 10000) . " sec"; - $responseMetadata = implode("
", $responseMetadata); - - $engineConfig = []; - $engineConfig[] = "Temperature: {$request["body"]["generationConfig"]["temperature"]}"; - $engineConfig[] = "Max Tokens: {$request["body"]["generationConfig"]["maxOutputTokens"]}"; - $engineConfig[] = "TopK: {$request["body"]["generationConfig"]["topK"]}"; - $engineConfig[] = "TopP: {$request["body"]["generationConfig"]["topP"]}"; - $engineConfig = implode("
", $engineConfig); - - $form["PromptTesterForm"]["testdata"]["testresults"] = [ - "#type" => "container", - '#attributes' => ['id' => ['edit-testresults']], - "output" => [ - "#type" => "fieldset", - "#title" => Markup::create("$type_label results"), - "outputtext" => [ - "#markup" => Markup::create($result) - ], - "moreinfo" => [ - "#type" => "details", - "#title" => "More Information", - "info" => [ - "#markup" => " + break; + default: + $fullprompt = $request["body"]["contents"][0]["parts"]; + $prompt = array_pop($fullprompt)["text"]; + $fullprompt = implode("
- ", array_column($fullprompt, "text")); + + $ai_engine = $response["ai_engine"] ?? "gemini-pro"; + + $requestSafety = []; + foreach($request["body"]["safetySettings"] as $safe) { + $requestSafety[] = "{$safe["category"]} threshold: {$safe["threshold"]}"; + } + $requestSafety = implode("
", $requestSafety); + + $responseSafety = []; + foreach($response[$ai_engine]["ratings"] as $safety) { + if (!empty($safety["category"])) { + $responseSafety[] = "{$safety["category"]} probability: {$safety["probabilityScore"]} ({$safety["probability"]})"; + $responseSafety[] = "{$safety["category"]} severity: {$safety["severityScore"]} ({$safety["severity"]})"; + } + else { + $responseSafety[] = "OVERALL probability: {$safety["probabilityScore"]} ({$safety["probability"]})"; + $responseSafety[] = "OVERALL severity: {$safety["severityScore"]} ({$safety["severity"]})"; + } + } + $responseSafety = implode("
", $responseSafety); + + $responseMetadata = []; + if ($response[$ai_engine]["usageMetadata"]) { + $responseMetadata[] = "Prompt Token Count: " . $response[$ai_engine]["usageMetadata"]["promptTokenCount"] ?? "N/A"; + $responseMetadata[] = "Candidates Token Count: " . $response[$ai_engine]["usageMetadata"]["candidatesTokenCount"] ?? "N/A"; + $responseMetadata[] = "Total Token Count: " . $response[$ai_engine]["usageMetadata"]["totalTokenCount"] ?? "N/A"; + } + $responseMetadata[] = "Analysis time: " . (intval(($response["elapsedTime"] ?? 0) * 10000) / 10000) . " sec"; + $responseMetadata = implode("
", $responseMetadata); + + $engineConfig = []; + $engineConfig[] = "Temperature: {$request["body"]["generationConfig"]["temperature"]}"; + $engineConfig[] = "Max Tokens: {$request["body"]["generationConfig"]["maxOutputTokens"]}"; + $engineConfig[] = "TopK: {$request["body"]["generationConfig"]["topK"]}"; + $engineConfig[] = "TopP: {$request["body"]["generationConfig"]["topP"]}"; + $engineConfig = implode("
", $engineConfig); + + $form["PromptTesterForm"]["testdata"]["testresults"] = [ + "#type" => "container", + '#attributes' => ['id' => ['edit-testresults']], + "output" => [ + "#type" => "fieldset", + "#title" => Markup::create("$type_label results"), + "outputtext" => [ + "#markup" => Markup::create($result) + ], + "moreinfo" => [ + "#type" => "details", + "#title" => "More Information", + "info" => [ + "#markup" => "
@@ -246,11 +307,13 @@ public function ajaxCallbackProcess(array $form, FormStateInterface $form_state)
Action$type_label
Full Prompt$fullprompt
AI Engine$ai_engine ({$request["endpoint"]})
Safety Parameters" . ($requestSafety??"N/a") . "
Reported Safety" . ($responseSafety??"N/a") . "
", + ] + ] ] - ] - ] - ]; + ]; + + } return $form["PromptTesterForm"]["testdata"]["testresults"]; } @@ -272,4 +335,47 @@ public function submitForm(array &$form, FormStateInterface $form_state) { // Not required for this test form. } + private function formatCitations($processor) { + $output = []; + $citations = $processor->getResults()["citations"] ?? []; + foreach($citations as $citation) { + $output[] = [ + '#type' => "fieldset", + 'citation' => [ + '#markup' => Markup::create( + " + + + +
title{$citation['title']}
doc{$citation['ref']}
link{$citation['uri']}
" + ) + ], + ]; + } + return $output; + } + private function formatResults($processor) { + $output = []; + $results = $processor->getResults()["search_results"] ?? []; + foreach($results as $result) { + $output[] = [ + '#type' => "fieldset", + 'result' => [ + '#markup' => Markup::create( + " + + + + + + +
title{$result['title']}
doc{$result['ref']}
link{$result['link']}
content{$result['content']}
description{$result['description']}
snippet{$result['snippet']}
" + ) + ], + ]; + } + return $output; + + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationPayload.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationPayload.php index f29d573079..bcae74a6c5 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationPayload.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationPayload.php @@ -4,10 +4,29 @@ use Drupal; +use Drupal\bos_google_cloud\Apis\v1alpha\answerGenerationSpec\AnswerGenerationSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\answerGenerationSpec\PromptSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\contentSearchSpec\ContentSearchSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\contentSearchSpec\ExtractiveContentSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\contentSearchSpec\SnippetSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\projects\locations\collections\dataStores\conversations\Converse; +use Drupal\bos_google_cloud\Apis\v1alpha\projects\locations\collections\dataStores\conversations\TextInput; +use Drupal\bos_google_cloud\Apis\v1alpha\projects\locations\collections\engines\servingConfigs\Answer; +use Drupal\bos_google_cloud\Apis\v1alpha\projects\locations\collections\engines\servingConfigs\Search; +use Drupal\bos_google_cloud\Apis\v1alpha\contentSearchSpec\SummarySpec; +use Drupal\bos_google_cloud\Apis\v1alpha\contentSearchSpec\ModelPromptSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\contentSearchSpec\ModelSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\contentSearchSpec\ChunkSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\projects\locations\evaluations\QueryExpansionSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\relatedQuestionsSpec\RelatedQuestionsSpec; +use Drupal\bos_google_cloud\Apis\v1alpha\safetySpec\SafetySpec; + class GcGenerationPayload { public const CONVERSATION = 0; - public const PREDICTION = 1; + public const SEARCH = 1; + public const PREDICTION = 2; + public const SEARCH_ANSWER = 16; /** * HARDCODED safety settings for bos_google_cloud gen-ai prediction services. @@ -50,7 +69,17 @@ public static function build(int $type, array $options):array|bool { ->error("Require Text and Prompt in payload (prompt:{$options["prompt"]},text:{$options["text"]}"); return FALSE; } - return self::buildConversation($options["prompt"], $options["text"], $options["conversation"] ?? [], $options["num_results"] ?? 5, $options["include_citations"] ?? TRUE); + return self::buildConversation($options); + + case self::SEARCH_ANSWER: + case self::SEARCH: + if (empty($options["text"]) || empty($options["prompt"])) { + Drupal::logger("bos_google_cloud") + ->error("Require Text and Prompt in payload (prompt:{$options["prompt"]},text:{$options["text"]}"); + return FALSE; + } + $options["type"] = $type; + return self::buildSearch($options); case self::PREDICTION: if (empty($options["prediction"]) || empty($options["generation_config"])) { @@ -69,11 +98,18 @@ public static function build(int $type, array $options):array|bool { /** * Produces the standardized payload for the conversations:converse endpoint. * - * @param string $prompt The prompt to use to guide the AI responses. - * @param string $text The conversation text to be processed by the AI. - * @param array $conversation An ongoing conversation to be passed to the AI. - * @param int $num_results Number of search results desired. - * @param bool $include_citations If citations should be included in the response. + * @param array $options An array of options for the conversation API + * string prompt - The prompt to use to guide the AI responses. + * string text - The conversation text to be processed by the AI. + * array conversation - An ongoing conversation to be passed to the AI. + * int num_results - Number of search results desired. + * bool include_citations - If citations should be included in the response. + * bool safe_search - If the API should conduct a safe search + * bool semantic_chunks - Improve results using semantic chunking. + * bool ignoreAdversarialQuery - + * bool ignoreNonSummarySeekingQuery - + * bool ignoreLowRelevantContent - + * bool ignoreJailBreakingQuery - * * @return array|bool * @@ -83,34 +119,142 @@ public static function build(int $type, array $options):array|bool { * * @see https://cloud.google.com/generative-ai-app-builder/docs/apis */ - private static function buildConversation(string $prompt, string $text, array $conversation, int $num_results, bool $include_citations): array|bool { + private static function buildConversation(array $options): array|bool { - $payload = [ - "query" => [ - "input" => $text, - // "context" => "", - ], - // "servingConfig" => "", - "safeSearch" => FALSE, - // "conversation" => $conversation, - "summarySpec" => [ - "summaryResultCount" => $num_results, - "modelSpec" => ["version" => "stable"], - "modelPromptSpec" => [ - "preamble" => GcGenerationPrompt::getPromptText("search", $prompt) - ], - "ignoreAdversarialQuery" => TRUE, - "ignoreNonSummarySeekingQuery" => TRUE, - "includeCitations" => $include_citations, - ], - ]; + // v1alpha version + $payload = new Converse(); + $payload->set("query", new TextInput([ + "input" => $options["text"] + ])); + $payload->set("safeSearch", $options["safe_search"] ?? TRUE); + $payload->set("summarySpec", new SummarySpec([ + "summaryResultCount" => $options["num_results"] ?? 5, + "includeCitations" => $options["include_citations"] ?? FALSE, + "ignoreAdversarialQuery" => $options["ignoreAdversarialQuery"] ?? TRUE, + "ignoreNonSummarySeekingQuery" => $options["ignoreNonSummarySeekingQuery"] ?? TRUE, + "ignoreLowRelevantContent" => $options["ignoreLowRelevantContent"] ?? TRUE, + "ignoreJailBreakingQuery" => $options["ignoreJailBreakingQuery"] ?? TRUE, + "languageCode" => NULL, + "modelPromptSpec" => new ModelPromptSpec([ + "preamble" => GcGenerationPrompt::getPromptText("search", $options["prompt"]) . " " . $options["extra_prompt"] + ]), + "modelSpec" => new ModelSpec([ + "version" => $options["model"] ?? "stable", + ]), + "useSemanticChunks" => $options["semantic_chunks"] ?? FALSE, + ])); - if (!empty($conversation)) { + if (!empty($options["conversation"])) { // Pick up the conversation. - $payload["conversation"] = self::sanitizeConversation($conversation); + $payload->set("conversation", self::sanitizeConversation($options["conversation"])); } - return $payload; + return $payload->toArray(); + + } + + /** + * Produces the standardized payload for the servingConfigs/search endpoint. + * + * @param array $options An array of options for the conversation API + * string prompt The prompt to use to guide the AI responses. + * string text The query to be processed by the AI. + * array conversation An ongoing conversation to be passed to the AI. + * int num_results Number of search results desired. + * bool include_citations If citations should be included in the response. + * bool safe_search If the API should conduct a safe search + * bool semantic_chunks Improve results using semantic chunking. + * bool ignoreAdversarialQuery - + * bool ignoreNonSummarySeekingQuery - + * bool ignoreLowRelevantContent - + * bool ignoreJailBreakingQuery - + * + * @return array|bool + * + * @throws \Exception + * @see https://cloud.google.com/generative-ai-app-builder/docs/reference/rest/v1alpha/projects.locations.collections.engines.servingConfigs + * @see https://cloud.google.com/generative-ai-app-builder/docs/reference/rest/v1alpha/projects.locations.collections.engines.servingConfigs/search + * + * @see https://cloud.google.com/generative-ai-app-builder/docs/apis + */ + private static function buildSearch(array $options): array|bool { + // v1alpha format + + switch($options["type"]) { + case self::SEARCH: + $payload = new Search(); + $payload->set("query", $options["text"]); + $payload->set("pageSize", $options["num_results"] ?? 5); + $queryExpansionSpec = new QueryExpansionSpec([ + "condition" => "AUTO", + "pinUnexpandedResults" => TRUE + ]); + $payload->set("queryExpansionSpec", $queryExpansionSpec); + $content_spec = [ + "snippetSpec" => new SnippetSpec([ + "returnSnippet" => TRUE, + ]), + "summarySpec" => new SummarySpec([ + "summaryResultCount" => ($options["num_results"] * 2) ?? 5, + "includeCitations" => $options["include_citations"] ?? FALSE, + "ignoreAdversarialQuery" => $options["ignoreAdversarialQuery"] ?? TRUE, + "ignoreNonSummarySeekingQuery" => $options["ignoreNonSummarySeekingQuery"] ?? TRUE, + "ignoreLowRelevantContent" => $options["ignoreLowRelevantContent"] ?? TRUE, + "ignoreJailBreakingQuery" => $options["ignoreJailBreakingQuery"] ?? TRUE, + "languageCode" => NULL, + "modelPromptSpec" => new ModelPromptSpec([ + "preamble" => GcGenerationPrompt::getPromptText("search", $options["prompt"]) . " " . $options["extra_prompt"] + ]), + "modelSpec" => new ModelSpec([ + "version" => $options["model"] ?? "stable", + ]), + "useSemanticChunks" => $options["semantic_chunks"] ?? FALSE, + ]), + "extractiveContentSpec" => new ExtractiveContentSpec([ + "maxExtractiveAnswerCount" => $options["num_results"], + "maxExtractiveSegmentCount" => 1, + "returnExtractiveSegmentScore" => FALSE, + "numPreviousSegments" => 0, + "numNextSegments" => 0 + ]), + "searchResultMode" => "DOCUMENTS", + "chunkSpec" => new ChunkSpec([ + "numPreviousChunks" => 0, + "numNextChunks" => 0, + ]) + ]; + if ($options["allow_conversation"]) { + unset($content_spec["summarySpec"]); + $payload->setSession($options["project_id"], $options["engine_id"], $options["session_id"] ?: "-"); + } + $payload->set("contentSearchSpec", new ContentSearchSpec($content_spec)); + $payload->set("safeSearch", $options["safe_search"] ?? TRUE); + $payload->set("relevanceThreshold", "RELEVANCE_THRESHOLD_UNSPECIFIED"); + return $payload->toArray(); + + case self::SEARCH_ANSWER: + $payload = new Answer(); + $payload->setQuery($options["text"], $options["query_id"], $options["project_id"]); + $payload->setSession($options["project_id"], $options["engine_id"], $options["session_id"]); + $payload->set("relatedQuestionsSpec", new RelatedQuestionsSpec(["enable" => $options["related_questions"] ?? FALSE])); + $payload->set("answerGenerationSpec", new AnswerGenerationSpec([ + "modelSpec" => new ModelSpec(["modelVersion" => $options["model"] ?? "stable"]), + "promptSpec" => new PromptSpec(["preamble" => GcGenerationPrompt::getPromptText("search", $options["prompt"]) . " " . $options["extra_prompt"]]), + "answerLanguageCode" => NULL, + "includeCitations" => $options["include_citations"] ?? FALSE, + "ignoreAdversarialQuery" => $options["ignoreAdversarialQuery"] ?? TRUE, + "ignoreNonAnswerSeekingQuery" => $options["ignoreNonSummarySeekingQuery"] ?? TRUE, + "ignoreLowRelevantContent" => $options["ignoreLowRelevantContent"] ?? TRUE, + "ignoreJailBreakingQuery" => $options["ignoreJailBreakingQuery"] ?? TRUE, + ])); + $payload->set("safeSearch", new SafetySpec([ + "enable" => $options["safe_search"] ?? TRUE + ])); + return $payload->toArray(); + + default: + return FALSE; + } } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationPrompt.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationPrompt.php index 01e07507b9..93463dd871 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationPrompt.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationPrompt.php @@ -3,6 +3,7 @@ namespace Drupal\bos_google_cloud; use Drupal; +use Drupal\bos_google_cloud\Services\GcConversation; use Drupal\bos_google_cloud\Services\GcSearch; use Drupal\bos_google_cloud\Services\GcTextRewriter; use Drupal\bos_google_cloud\Services\GcTextSummarizer; @@ -74,12 +75,18 @@ class GcGenerationPrompt { */ public static function getPrompts(string $config_key): array { + // Search and conversation use shared prompts. + if ($config_key == GcConversation::id()) { + $config_key = GcSearch::id(); + } + $config = Drupal::config("bos_google_cloud.prompts")->get($config_key) ?? ""; switch ($config_key) { case "base": $defaults = self::BASE_PROMPTS; break; case GcTextSummarizer::id(): $defaults = self::SUMMARIZE_PROMPTS; break; case GcTextRewriter::id(): $defaults = self::REWRITE_PROMPTS; break; + case GcConversation::id(): case GcSearch::id(): $defaults = self::SEARCH_CONVERSATION_PROMPTS; break; case GcTranslation::id(): $defaults = self::TRANSLATION_LANGUAGES; break; default: return []; @@ -155,12 +162,12 @@ public function buildForm(array &$form): void { 'search_wrapper' => [ '#type' => 'details', '#title' => t('Search Prompts'), - "search" => [ + GcSearch::id() => [ '#type' => 'textarea', '#title' => t('Prompts used by Search and Conversation'), '#description' => $description, - '#default_value' => self::stringifyPrompts(self::getPrompts("search")), - '#rows' => count(self::getPrompts("search"))+2, + '#default_value' => self::stringifyPrompts(self::getPrompts(GcSearch::id())), + '#rows' => count(self::getPrompts(GcSearch::id()))+2, '#required' => TRUE, ], ], diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationURL.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationURL.php index fcc93ef8bc..0c465ddf13 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationURL.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/GcGenerationURL.php @@ -7,7 +7,14 @@ class GcGenerationURL { public const CONVERSATION = 0; - public const PREDICTION = 1; + + public const SEARCH = 1; + public const PREDICTION = 2; + public const DATASTORE = 4; + public const PROJECT = 8; + public const SEARCH_ANSWER = 16; + public const ENGINE = 32; + /** * Creates the URL for the endpoint base don the specified $type. @@ -21,6 +28,14 @@ public static function build(int $type, array $options):string|bool { switch ($type) { + case self::SEARCH_ANSWER: + case self::SEARCH: + if (empty($options["endpoint"]) || empty($options["project_id"]) + || empty($options["location_id"]) || empty($options["engine_id"])) { + return FALSE; + } + return self::buildSearch($options["endpoint"], $options["project_id"], $options["location_id"], $options["engine_id"], $type); + case self::CONVERSATION: if (empty($options["endpoint"]) || empty($options["project_id"]) || empty($options["location_id"]) || empty($options["datastore_id"])) { @@ -35,6 +50,26 @@ public static function build(int $type, array $options):string|bool { } return self::buildPrediction($options["endpoint"], $options["project_id"], $options["location_id"], $options["model_id"]); + case self::DATASTORE: + if (empty($options["endpoint"]) || empty($options["project_id"]) + || empty($options["location_id"])) { + return FALSE; + } + return self::buildDataStore($options["endpoint"], $options["project_id"], $options["location_id"]); + + case self::ENGINE: + if (empty($options["endpoint"]) || empty($options["project_id"]) + || empty($options["location_id"])) { + return FALSE; + } + return self::buildEngine($options["endpoint"], $options["project_id"], $options["location_id"]); + + case self::PROJECT: + if (empty($options["endpoint"])) { + return FALSE; + } + return self::buildProject($options["endpoint"]); + default: return FALSE; } @@ -67,6 +102,33 @@ private static function buildConversation(string $endpoint, string $project_id, } + /** + * Produces the standardized URL/endpoint for the projects.locations.collections.engines.servingConfigs.search + * endpoint. + * + * @param string $endpoint + * @param string $project_id + * @param string $location_id + * @param string $engine_id + * + * @return string + * + * @see https://cloud.google.com/generative-ai-app-builder/docs/apis + * @see https://cloud.google.com/generative-ai-app-builder/docs/reference/rest/v1alpha/projects.locations.collections.engines.servingConfigs + * @see https://cloud.google.com/generative-ai-app-builder/docs/reference/rest/v1alpha/projects.locations.collections.engines.servingConfigs/search + */ + private static function buildSearch(string $endpoint, string $project_id, string $location_id, string $engine_id, int $type): string { + + $url = $endpoint; + $url .= "/v1alpha/projects/$project_id"; + $url .= "/locations/$location_id"; + $url .= "/collections/default_collection/engines/$engine_id"; + $url .= "/servingConfigs/default_search:" ; + $url .= ($type == self::SEARCH ? "search" : "answer"); + return $url; + + } + /** * Produces the standardized URL/endpoint for the models:streamGenerateContent * endpoint. @@ -88,6 +150,60 @@ private static function buildPrediction(string $endpoint, string $project_id, st } + /** + * Produces the standardized URL/endpoint to query DataStores + * + * @param string $endpoint + * @param string $project_id + * @param string $location_id + * + * @return string + */ + private static function buildDataStore(string $endpoint, string $project_id, string $location_id): string { + + $url = $endpoint; + $url .= "/v1/projects/$project_id"; + $url .= "/locations/$location_id"; + $url .= "/collections/default_collection/dataStores"; + return $url; + + } + + /** + * Produces the standardized URL/endpoint to query Engines + * + * @param string $endpoint + * @param string $project_id + * @param string $location_id + * + * @return string + */ + private static function buildEngine(string $endpoint, string $project_id, string $location_id): string { + + $url = $endpoint; + $url .= "/v1/projects/$project_id"; + $url .= "/locations/$location_id"; + $url .= "/collections/default_collection/engines"; + return $url; + + } + + /** + * Produces the standardized URL/endpoint to query projects + * + * @param string $endpoint + * + * @return string + */ + private static function buildProject(string $endpoint): string { + + // For this to work the service account needs resourcemanager.projects.list + // permission on the organization. Right now, this has not been granted. + $url = "https://cloudresourcemanager.googleapis.com/v3/projects"; + return $url; + + } + /** * Check to see if we think the API quota has been exceeded. * @@ -112,6 +228,7 @@ public static function quota_exceeded(int $type): bool { ->get("vertex_ai.quota") ?? 10; // # requests allowed in the window. break; + case self::SEARCH: case self::CONVERSATION: // Current limits found here: // @see https://console.cloud.google.com/iam-admin/quotas?project=vertex-ai-poc-406419&pageState=(%22allQuotasTable%22:(%22f%22:%22%255B%257B_22k_22_3A_22Name_22_2C_22t_22_3A10_2C_22v_22_3A_22_5C_22Conversation%2520other%2520operations%2520per%2520minute_5C_22_22_2C_22s_22_3Atrue_2C_22i_22_3A_22displayName_22%257D_2C%257B_22k_22_3A_22_22_2C_22t_22_3A10_2C_22v_22_3A_22_5C_22OR_5C_22_22_2C_22o_22_3Atrue_2C_22s_22_3Atrue%257D_2C%257B_22k_22_3A_22Name_22_2C_22t_22_3A10_2C_22v_22_3A_22_5C_22Conversational%2520search%2520read%2520requests%2520per%2520minute_5C_22_22_2C_22s_22_3Atrue_2C_22i_22_3A_22displayName_22%257D%255D%22)) diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Plugin/WebformHandler/GcBigQueryHandler.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Plugin/WebformHandler/GcBigQueryHandler.php new file mode 100644 index 0000000000..43098c29ae --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Plugin/WebformHandler/GcBigQueryHandler.php @@ -0,0 +1,118 @@ + '', + 'project' => '', + 'table' => '', + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritDoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $service_accounts = GcAuthenticator::SVS_ACCOUNT_LIST; + + $form['bigquery_form_handler'] = [ + '#type' => 'details', + '#title' => $this->t('Google Cloud: Big Query Submission.'), + '#open' => TRUE, + ]; + $form['bigquery_form_handler']['service_account'] = [ + '#type' => 'select', + '#title' => $this->t('Goocle Cloud Service Account'), + '#options' => $service_accounts, + '#default_value' => $this->configuration['service_account'], + '#description' => $this->t('Select the service account to use.'), + ]; + $form['bigquery_form_handler']['project'] = [ + '#type' => 'textfield', + '#title' => $this->t('Google Cloud Platform Project to use.'), + '#default_value' => $this->configuration['project'], + '#description' => $this->t('Enter the name or ID of the Big Query-enabled Project.'), + ]; + $form['bigquery_form_handler']['dataset'] = [ + '#type' => 'textfield', + '#title' => $this->t('The dataset to use.'), + '#default_value' => $this->configuration['dataset'], + '#description' => $this->t('Enter the name of the Big Query dataset.'), + ]; + $form['bigquery_form_handler']['table'] = [ + '#type' => 'textfield', + '#title' => $this->t('Table'), + '#default_value' => $this->configuration['table'], + '#description' => $this->t('Enter the table into which the submissions are to be posted.'), + ]; + return parent::buildConfigurationForm($form, $form_state); + } + + /** + * {@inheritdoc} 738313172788 + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void { + $this->configuration = $form_state->getUserInput()["settings"]['bigquery_form_handler']; + } + + /** + * {@InheritDoc} + * Require that something was entered on the form. + */ + public function validateForm(array &$form, FormStateInterface $form_state, WebformSubmissionInterface $webform_submission) { + $anything = FALSE; + foreach ($webform_submission->getRawData() as $question => $answer) { + $anything = $anything || !empty($answer); + } + if (!$anything) { + $form_state->setErrorByName("how_easy_was_it_to_find_the_information_you_were_looking_for", "Please enter some information!"); + } + } + + /** + * {@inheritdoc} + */ + public function postSave(WebformSubmissionInterface $webform_submission, $update = TRUE): void { + // The form is being submitted. Handle it here. + $api = new GcBigQuery($this->configuration['service_account'], $this->configuration['project'], $this->configuration['dataset']); + try { + $api->insertAll($this->configuration['table'], $webform_submission->getData()); + } + catch (Exception $e) { + $this->messenger() + ->addError($this->t('Error: @message', ['@message' => $api->error() ?? $e->getMessage()])); + } + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcAgentBuilderInterface.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcAgentBuilderInterface.php new file mode 100644 index 0000000000..a421f8f93f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcAgentBuilderInterface.php @@ -0,0 +1,64 @@ +error()); } + /** + * @inheritDoc + */ + public function hasFollowup(): bool { + return FALSE; + } + + /** + * @inheritDoc + */ + public function getSettings(): array { + return []; + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + return []; + } + + /** + * @inheritDoc + */ + public static function ajaxTestService(array &$form, FormStateInterface $form_state): array { + // not required. + return []; + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcBigQuery.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcBigQuery.php new file mode 100644 index 0000000000..a9b8a977a6 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcBigQuery.php @@ -0,0 +1,191 @@ + "", + 'project' => "", + 'dataset' => "", + ]; + + public function __construct(string $service_account, string $project, string $dataset) { + $this->connectionString["account"] = $service_account; + $this->connectionString["project"] = $project; + $this->connectionString["dataset"] = $dataset; + parent::__construct(); + } + + /** + * Inserts multiple records into a specified BigQuery table. + * + * @param string $table + * The name of the BigQuery table where the records will be inserted. + * @param array $records + * An array of associative arrays representing the records to be inserted. + * @param int $retry + * (Optional) The retry attempt counter. Defaults to 0. + * + * @return bool + * Returns TRUE if the insertion is successful. + * + * @throws \Exception + * If any error occurs during the insertion process. + */ + public function insertAll(string $table, array $records, int $retry = 0): bool { + + try { + $headers = []; + $headers = array_merge($headers, $this->authenticate()); + $url = $this->buildUrl("insertAll", $table); + $payload = $this->buildPayload("insertAll", $records); + + $results = $this->post($url, $payload, $headers); + + if ($this->error()) { + throw new Exception($this->error()); + } + elseif (!$results) { + $this->error = "Post Failed: Code:{$this->response["http_code"]}"; + throw new Exception($this->error()); + } + elseif ($this->http_code() == 401) { + if (empty($retry)) { + // The token is invalid, because we are caching for the lifetime of + // the token, this probably means it has been refreshed elsewhere. + $this->authenticator->invalidateAuthToken($this->connectionString["account"]); + $this->insertAll($table, $records, 1); + } + $this->error = "Could not Authenticate."; + throw new Exception($this->error()); + } + elseif ($this->http_code() == 403) { + $this->error = "No permission."; + throw new Exception($this->error()); + } + elseif ($this->http_code() == 200) { + return TRUE; + } + else { + $this->error = "Unknown Error: code:{$this->response["http_code"]}"; + throw new Exception($this->error()); + } + + } + catch (Exception $e) { + throw new Exception($e->getMessage()); + } + } + + /** + * Authenticates the user and retrieves the authorization token. + * + * @return array + * An associative array containing the authorization token with the key + * 'Authorization'. + * + * @throws \Exception + * If there is an error obtaining the access token. + */ + private function authenticate(): array { + // Get token. + try { + $this->authenticator = new GcAuthenticator(); + return [ + "Authorization" => $this->authenticator + ->getAccessToken($this->connectionString["account"], "Bearer"), + ]; + } + catch (Exception $e) { + $this->error = $e->getMessage(); + throw new Exception("Error getting access token."); + } + } + + /** + * Constructs a Google Cloud BigQuery URL based on the provided parameters. + * + * @param string $action + * The specific action endpoint to be performed (e.g., 'insert', 'query'). + * @param string $table + * The name of the table within the dataset. + * @param array $connection_string + * An associative array containing the connection details, specifically + * keys for 'project' and 'dataset'. Defaults to class connectionString. + * + * @return string + * The constructed URL for the specified BigQuery operation. + * + * @throws \Exception + * If any of the required connection details or parameters are missing. + */ + private function buildUrl(string $action, string $table, array $connection_string = []):string { + $project = $connection_string["project"] ?: ($this->connectionString["project"] ?: NULL); + $dataset = $connection_string["dataset"] ?: ($this->connectionString["dataset"] ?: NULL); + if ($project && $dataset && $table && $action) { + return "https://bigquery.googleapis.com/bigquery/v2/projects/$project/datasets/$dataset/tables/$table/$action"; + } + throw new Exception("Missing connection detail/s: Unable to build GC Big Query url"); + } + + /** + * Constructs the payload based on the provided action and records. + * + * @param string $action + * The action to be performed (e.g., 'insert', 'update'). + * @param array $records + * An array of records that form the payload. + * + * @return array + * The constructed payload. + */ + private function buildPayload(string $action, array $records = []):array { + switch ($action) { + case "insertAll": + default: + return $records; + } + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcConversation.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcConversation.php index 0ba6e75089..e0e50c7546 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcConversation.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcConversation.php @@ -4,6 +4,7 @@ use Drupal; use Drupal\bos_core\Controllers\Curl\BosCurlControllerBase; +use Drupal\bos_google_cloud\GcGenerationPrompt; use Drupal\bos_google_cloud\GcGenerationURL; use Drupal\bos_google_cloud\GcGenerationPayload; use Drupal\Core\Config\ConfigFactory; @@ -46,7 +47,18 @@ class GcConversation extends BosCurlControllerBase implements GcServiceInterface */ protected GcAuthenticator $authenticator; - public function __construct(LoggerChannelFactory $logger, ConfigFactory $config) { + /** @var array Standardized search response. Clone of class AiSearchResponse.*/ + protected array $sc_response = [ + "ai_answer" => '', // Text only answer from Vertex + "body" => '', // Markup response from Vertex - with citations + "citations" => [], // Array of citations + "session_id" => '', // The unique ID for this conversation + "metadata" => [], // Safety and other metadata returned from search + "references" => [], // References .. ??? + "search_results" => [], // List of search result objects + ]; + + public function __construct(LoggerChannelFactory $logger, ConfigFactory $config) { // Load the service-supplied variables. $this->log = $logger->get('bos_google_cloud'); @@ -89,7 +101,8 @@ public function setServiceAccount(string $service_account):GcConversation { * Adds some text to a conversation, using a pre-defined prompt. * * @param array $parameters Array containing "text" URLencode text to be - * summarized, and "prompt" A search type prompt. + * summarized, and "prompt" A search type prompt "session_id" unique id + * to continue a previous conversation. * * @return string * / @@ -101,29 +114,35 @@ public function execute(array $parameters = []): string { $this->error = "Some text for the conversation is needed."; return $this->error(); } - - $parameters["prompt"] = $parameters["prompt"] ?? "default"; - - $settings = $this->settings["conversation"] ?? []; + elseif (empty($this->settings[$this->id()])) { + $this->error = "The conversation API settings are empty or missing."; + return $this->error(); + } // check quota. if (GcGenerationURL::quota_exceeded(GcGenerationURL::CONVERSATION)) { - $this->error = "Quota exceeded for this API"; + $this->error = "Quota exceeded for Discovery API"; return $this->error; } + // Specify the prompt to use. + $parameters["prompt"] = $parameters["prompt"] ?? "default"; + // Specify the LLM to use. + $parameters["model"] = $this->settings[$this->id()]["model"] ?? "stable"; + // Manage conversations. - if ($settings["allow_conversation"] ?? FALSE || $parameters["allow_conversation"] ?? FALSE) { + if ($this->settings[$this->id()]["allow_conversation"] ?? FALSE && $parameters["allow_conversation"] ?? FALSE) { // Find any previous conversation and save in the parameters object. - if (empty($parameters["conversation_id"])) { + if (empty($parameters["session_id"])) { $parameters["conversation"] = []; } else { // try to retrieve the previous conversation. - $parameters["conversation"] = Drupal::service("keyvalue.expirable") + $KeyValueService = Drupal::service("keyvalue.expirable"); + $parameters["conversation"] = $KeyValueService ->get(self::id()) - ->get($parameters["conversation_id"]) ?? []; + ->get($parameters["session_id"]) ?? []; } } @@ -131,7 +150,7 @@ public function execute(array $parameters = []): string { // Get token. try { $headers = [ - "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer") + "Authorization" => $this->authenticator->getAccessToken($this->settings[$this->id()]['service_account'], "Bearer") ]; } catch (Exception $e) { @@ -139,13 +158,29 @@ public function execute(array $parameters = []): string { return $this->error(); } - $url = GcGenerationURL::build(GcGenerationURL::CONVERSATION, $settings); + // If we have overrides for the default projects or datastores, apply the + // override here. + if (!empty($parameters["service_account"])) { + $this->settings[$this->id()]['service_account'] = $parameters["service_account"]; + } + if (!empty($parameters["project_id"])) { + $this->settings[$this->id()]['project_id'] = $parameters["project_id"]; + } + if (!empty($parameters["datastore_id"])) { + $this->settings[$this->id()]['datastore_id'] = $parameters["datastore_id"]; + } + if (!empty($parameters["engine_id"])) { + $this->settings[$this->id()]['engine_id'] = $parameters["engine_id"]; + } + + $url = GcGenerationURL::build(GcGenerationURL::CONVERSATION, $this->settings[$this->id()]); if (!$payload = GcGenerationPayload::build(GcGenerationPayload::CONVERSATION, $parameters)) { $this->error = "Could not build Payload"; return $this->error; } + // Query the AI. $results = $this->post($url, $payload, $headers); if ($this->http_code() == 200 && !$this->error()) { @@ -155,25 +190,62 @@ public function execute(array $parameters = []): string { return $this->error(); } - $this->response["ai_answer"] = $results["reply"]["reply"]; - $this->loadSafetyRatings($results["reply"]["summary"]["safetyAttributes"]); + // Gather vertex conversation metadata. + $metadata = $this->loadMetadata($parameters); + // Process safety information into metadata. + if (!empty($results["reply"]["summary"]["safetyAttributes"])) { + $metadata += $this->loadSafetyRatings($results["reply"]["summary"]["safetyAttributes"]); + } + + // Load up the standardized Search response. + $this->sc_response = [ + 'body' => $results["reply"]["reply"], + 'metadata' => $metadata, + ]; + + // Check for Out-of-scope response. + if (!empty($this->response["body"]["reply"]["summary"]["summarySkippedReasons"])) { + $this->sc_response['violations'] = implode(', ', $this->response["body"]["reply"]["summary"]["summarySkippedReasons"]); + $this->response["body"] = $results["reply"]["summary"]["summaryText"]; + } + + // Include any citations. + else if ($parameters["include_citations"] ?? FALSE) { + // Load the citations + $this->sc_response['citations'] = $this->loadCitations( + $this->response["body"]["reply"]["summary"]["summaryWithMetadata"]["citationMetadata"]["citations"] ?? [], + $this->response["body"]["reply"]["summary"]["summaryWithMetadata"]["references"] ?? [], + $this->sc_response["body"] + ); + } + + else { + // Use the summary text with citations. + if (!empty($results["reply"]["summary"]["summaryWithMetadata"]["summary"])) { + $this->sc_response['body'] = $results["reply"]["summary"]["summaryWithMetadata"]["summary"]; + } + } + + // Add in the Search Results + $this->sc_response['search_results'] = $this->loadSearchResults($this->response["body"]["searchResults"] ?? []); - if ($settings["allow_conversation"] ?? TRUE) { - // Save the conversation as keyvalue with the conversation_id as key. - $this->response["conversation_id"] = $results["conversation"]["userPseudoId"]; + // Manage the conversation. + if ($this->settings[$this->id()]["allow_conversation"] ?? FALSE) { + // Save the conversation as keyvalue with the session_id as key. + $this->sc_response['session_id'] = $results["conversation"]["userPseudoId"]; Drupal::service("keyvalue.expirable") ->get(self::id()) - ->setWithExpire($this->response["conversation_id"], $results["conversation"], 300); + ->setWithExpire($this->sc_response['session_id'], $results["conversation"], 300); } - return $this->formattedResponse(); + return $this->sc_response['body']; } elseif ($this->http_code() == 401) { // The token is invalid, because we are caching for the lifetime of the // token, this probably means it has been refreshed elsewhere. - $this->authenticator->invalidateAuthToken($settings["service_account"]); + $this->authenticator->invalidateAuthToken($this->settings[$this->id()]["service_account"]); if (empty($parameters["invalid-retry"])) { $parameters["invalid-retry"] = 1; return $this->execute($parameters); @@ -192,17 +264,21 @@ public function execute(array $parameters = []): string { } - private function formattedResponse(): string { - $data = $this->response["body"]["reply"]["summary"]["summaryWithMetadata"]; - + /** + * Takes the output and turns it into an HTML block. + * ToDo: Change this into a twig templated object. + * + * @return string + */ + public function render(): string { $refs = []; - foreach($data["references"] as $key => $reference) { + foreach($this->sc_response["references"] as $key => $reference) { $ref_id = $key + 1; $refs[$key] = "{$reference["title"]}"; } $cites = []; - foreach($data["citationMetadata"]["citations"] as $citation) { + foreach($this->sc_response["citations"] as $citation) { foreach ($citation["sources"] as $key => $source) { $ref_id = $source["referenceIndex"] ?? $key; $cites[$ref_id] = $refs[$ref_id]; @@ -217,7 +293,6 @@ private function formattedResponse(): string { $citations .= " \n"; } - $data = $this->response["body"]; $results = "
\n"; $results .= "
{$this->response["ai_answer"]}
\n"; $results .= "
\n"; @@ -226,7 +301,7 @@ private function formattedResponse(): string { $results .= "
\n"; $results .= "
\n"; $results .= "
RESULTS
\n"; - foreach($data["searchResults"] as $key => $result) { + foreach($this->sc_response["search_results"] as $key => $result) { $res_id = $key + 1; $ans = ''; $snip = ""; @@ -262,23 +337,241 @@ private function formattedResponse(): string { } /** - * Update the $this->response["conversation"]["ratings"] array if the safety scores - * in $ratings are higher (less safe) than those already stored. + * Return the processed results in a standardized array. + * @return array + */ + public function getResults(): array { + return $this->sc_response; + } + + /** + * Establish the safety scores and retuurn. + * Only save safety scores in $ratings are higher (less safe) than those + * already stored. * - * @param array $ratings The safetyRatings from a gemini ::predict call. + * @param array $ratings The safetyRatings from vertex. * - * @return void + * @return array */ - private function loadSafetyRatings(array $ratings): void { + private function loadSafetyRatings(array $ratings): array { + + $output = []; - if (!isset($this->response["body"]["safetyRatings"])) { - $this->response["body"]["safetyRatings"] = []; + foreach(($ratings["categories"] ?? []) as $key => $rating) { + $output[$rating] = $ratings["scores"][$key]; } - foreach($ratings["categories"] as $key => $rating) { - $this->response["body"]["safetyRatings"][$rating] = $ratings["scores"][$key]; + return $output; + + } + + /** + * Load Search Results into a simple, standardized search output format. + * Also de-duplicates the results based on the ultimate node which is + * referenced in the result link. + * + * The array returned is a clone of the array in aiSearchResult (bos_search), + * but we have copied so as not to create a dependedncy between these modules + * at this point. + * + * @param array $results Output from AI Model + * + * @return array Standardized & simplified array of search results. + */ + private function loadSearchResults(array $results): array { + $output = []; + + if (empty($results)) { + return []; } + $alias_manager = \Drupal::service('path_alias.manager'); + $redirect_manager = \Drupal::service('redirect.repository'); + + $citations = $this->sc_response["citations"] ?: []; + + foreach($results as $result) { + + // Check if this result is already showing in the citations.h + $is_citation = FALSE; + if (!empty($citations)) { + foreach ($citations as $key => $citation) { + if ($citation["id"] == $result["id"]) { + // Mark results as being in the citations set + $is_citation = TRUE; + // Mark citation as being in results set. + $this->sc_response["citations"][$key]["is_result"] = TRUE; + break; + } + } + } + + /** Standardizes search result - output array is a clone of class aiSearchResult. */ + + $path_alias = explode(".gov",$result["document"]["derivedStructData"]["link"],2)[1]; + if (!empty($path_alias)) { + + // Strip out the alias from any other querystings etc + $path_alias = explode('?', $path_alias, 2); + $path_alias = explode('#', $path_alias[0], 2)[0]; + + // get the nid for this page alias (to prevent duplicates) + $path = $alias_manager->getPathByAlias($path_alias); + $path_parts = explode('/', $path); + $nid = array_pop($path_parts); + + if (!is_numeric($nid)) { + // If we can't get the node ID then it is possibly a redirect to + // another page, so try to track that down... + + $redirects = $redirect_manager->findBySourcePath(trim($path_alias, "/")); + if (!empty($redirects)) { + $redirect = reset($redirects); + $original_alias = explode(":", $redirect->getRedirect()['uri'], 2)[1] ?? $redirect->getRedirect()['uri']; + $path = $alias_manager->getPathByAlias($original_alias); + $path_parts = explode('/', $path); + $nid = array_pop($path_parts); + } + } + + if (!is_numeric($nid)) { + // Well ... interesting. + // Set the nid equal to the original node path so at least we + // de-duplicate. + $nid = $path; + } + + } + + $node = \Drupal::entityTypeManager()->getStorage('node')->load($nid); + $description = ""; + if ($node && $node->hasField("field_intro_text")) { + $description = $node->get("field_intro_text")->value; + } + if ($node && $node->hasField("body")) { + $description .= $node->get("body")->summary ?: $node->get("body")->value; + } + if (empty($description) && $node && $node->hasField("field_need_to_know")) { + $description = $node->get("field_need_to_know")->value; + } + + $title = explode("|", $result['document']['derivedStructData']['title'], 2)[0]; + $output[$result['id']] = [ + "content" => $result['document']['derivedStructData']['extractive_answers'][0]['content'], + "description" => trim(strip_tags($description)), + "id" => $result['id'], + "is_citation" => $is_citation, + "link" => $result['document']['derivedStructData']['link'], + "link_title" => $result['document']['derivedStructData']['displayLink'], + "ref" => $result['document']['name'], + "snippet" => $result['document']['derivedStructData']['snippets'][0]['snippet'] ?: "", + "title" => trim($title), + ]; + + + } + return array_values($output); + } + + /** + * Load Vertex available metadata into array and return. + * + * @param array $metadata + * + * @return array + */ + private function loadMetadata(array $metadata) { + $map = [ + "session_id" => "Drupal Internal", + ]; + $exclude_meta = [ + "conversation", + "session_id" + ]; + foreach($metadata as $key => $value) { + $node = $map[$key] ?? "Request"; + if (!in_array($key, $exclude_meta)) { + $output[$node][ucwords(str_replace("_", " ", $key))] = [ + "key" => $key, + "value" => $value, + ]; + } + } + $output[$node]["Full Prompt"] = [ + "key" => "Full Prompt", + "value" => $this->request["body"]["summarySpec"]["modelPromptSpec"]["preamble"], + ]; + foreach($this->settings[$this->id()] as $key => $value) { + $node = $map[$key] ?? "Model Config"; + $output[$node][ucwords(str_replace("_", " ", $key))] = [ + "key" => $key, + "value" => $value + ]; + } + $output["Model State"]["Current Conversation Length"] = ["key" => "conversation_length", "value" => count($this->response["body"]["conversation"]["messages"]) / 2]; + $output["Model Response"]["Endpoint"] = ["key" => "conversation_endpoint", "value" => $this->request["protocol"] . "//" . $this->request["host"] . '/' . $this->request["endpoint"]]; + $output["Model Response"]["Conversation"] = ["key" => "conversation_name", "value" => $this->response["body"]["conversation"]["name"]]; + $output["Model Response"]["State"] = ["key" => "conversation_state", "value" => $this->response["body"]["conversation"]["state"]]; + $output["Model Response"]["PseudoId"] = ["key" => "conversation_ref", "value" => $this->response["body"]["conversation"]["userPseudoId"]]; + $output["Model Response"]["Drupal Internal Id"] = ["key" => "session_id", "value" => $metadata["session_id"] ?? ""]; + $output["Model Response"]["Query Duration"] = ["key" => "conversation_query_duration", "value" => $this->response["elapsedTime"]]; + $output["Model Response"]["Search Results Returned"] = ["key" => "results_length", "value" => count($this->response["body"]["searchResults"] ?? [])]; + $output["Model Response"]["Citations Returned"] = ["key" => "citations_length", "value" => count($this->response["body"]["reply"]["summary"]["summaryWithMetadata"]["citationMetadata"]["citations"] ?? [])]; + return $output; + } + + /** + * Creates a unified citation array from a list of citations and references. + * + * @param array $citations Citations from Vertex + * @param array $references References from Vertex + * + * @return array a unified array of citations with their references. + */ + private function loadCitations(array $citations, array $references, string &$body): array { + $output = []; + + foreach ($references as $key => $reference) { + $output[$key] = $reference; + $output[$key]["title"] = trim(explode("|", $output[$key]["title"], 2)[0]); + $output[$key]["ref"] = $output[$key]["document"]; + $ref = explode("/", $output[$key]["document"]); + $output[$key]["id"] = array_pop($ref); + $output[$key]["locations"] = []; + // $output[$key]["original_key"] = $key; + + foreach ($citations as $citation) { + foreach ($citation["sources"] as $source) { + if (($source["referenceIndex"] ?? 0) == $key) { + $output[$key]["locations"][] = [ + "startIndex" => $citation["startIndex"] ?? 0, + "endIndex" => $citation["endIndex"] ?? strlen($body), + ]; + } + } + } + + unset($output[$key]["document"]); + } + + // reindex the output array, keep the original key to match the citation #'s + // and replace text on the page + $out = []; + $new_key = 1; + foreach ($output as $key => $value) { + if (!empty($value["locations"])) { + $value["original_key"] = $key + 1; + $body = preg_replace("~\[" . $value["original_key"] . "\]~", "[" . $new_key . "]", $body); + $body = preg_replace("~, " . $value["original_key"] . "~", "[" . $new_key . "]", $body); + $body = preg_replace("~" . $value["original_key"] . " ,~", "[" . $new_key . "]", $body); + $out[$new_key++] = $value; + } + } + + // Make the index numbers sequential, starting at 1 + + // Todo: add links into the body ? + return $out; } /** @@ -288,8 +581,10 @@ public function buildForm(array &$form): void { $project_id="612042612588"; $model_id="drupalwebsite_1702919119768"; + $engine_id="oeoi-search-pilot_1726266124376"; $location_id="global"; $endpoint="https://discoveryengine.googleapis.com"; + $model="stable"; $settings = $this->settings['conversation'] ?? []; @@ -326,6 +621,16 @@ public function buildForm(array &$form): void { "placeholder" => 'e.g. ' . $model_id, ], ], + 'engine_id' => [ + '#type' => 'textfield', + '#title' => t('Engine'), + '#description' => t(''), + '#default_value' => $settings['engine_id'] ?? $engine_id, + '#required' => TRUE, + '#attributes' => [ + "placeholder" => 'e.g. ' . $engine_id, + ], + ], 'location_id' => [ '#type' => 'textfield', '#title' => t('Location (always global for now)'), @@ -347,6 +652,17 @@ public function buildForm(array &$form): void { "placeholder" => 'e.g. ' . $endpoint, ], ], + 'model' => [ + '#type' => 'select', + '#title' => t('The LLM model to use'), + '#description' => t('This is the model that will be used.
Best to set to "stable" for latest stable release (which typically is frozen and only updated periodically) or "preview" for the latest model (which is more experimental and can be updated more frequently).
See https://cloud.google.com/generative-ai-app-builder/docs/answer-generation-models#models'), + '#default_value' => $settings['model'] ?? $model, + '#options' => [ + 'stable' => 'Stable', + 'preview' => 'Preview', + ], + '#required' => TRUE, + ], 'service_account' => [ '#type' => 'select', '#title' => t('The default service account to use'), @@ -400,15 +716,19 @@ public function submitForm(array $form, FormStateInterface $form_state): void { if ($config->get("conversation.project_id") !== $values['project_id'] ||$config->get("conversation.datastore_id") !== $values['datastore_id'] + ||$config->get("conversation.engine_id") !== $values['engine_id'] ||$config->get("conversation.location_id") !== $values['location_id'] ||$config->get("conversation.service_account") !== $values['service_account'] ||$config->get("conversation.allow_conversation") !== $values['allow_conversation'] + ||$config->get("conversation.model") !== $values['model'] ||$config->get("conversation.endpoint") !== $values['endpoint']) { $config->set("conversation.project_id", $values['project_id']) ->set("conversation.datastore_id", $values['datastore_id']) + ->set("conversation.engine_id", $values['engine_id']) ->set("conversation.location_id", $values['location_id']) ->set("conversation.allow_conversation", $values['allow_conversation']) ->set("conversation.endpoint", $values['endpoint']) + ->set("conversation.model", $values['model']) ->set("conversation.service_account", $values['service_account']) ->save(); } @@ -422,7 +742,6 @@ public function validateForm(array $form, FormStateInterface &$form_state): void // not required } - /** * Ajax callback to test Conversation Service. * @@ -454,4 +773,158 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st } + /** + * @inheritDoc + */ + public function hasFollowup(): bool { + return $this->config->get("conversation.allow_conversation"); + } + + /** + * @inheritDoc + */ + public function getSettings(): array { + return $this->settings[$this->id()]; + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + return GcGenerationPrompt::getPrompts($this->id()); + } + + /** + * @inheritDoc + */ + public function availableDataStores(?string $service_account, ?string $project_id): array { + + $settings = $this->settings[$this->id()]; + + if (!empty($service_account) && $service_account != "default") { + $settings['service_account'] = $service_account; + } + if (!empty($project_id) && $project_id != "default") { + $settings['project_id'] = $project_id; + } + + // Get token. + try { + $headers = [ + "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer") + ]; + } + catch (Exception $e) { + $this->error = $e->getMessage() ?? "Error getting access token."; + return []; + } + + $url = GcGenerationURL::build(GcGenerationURL::DATASTORE, $settings); + + // Query the AI. + try { + $results = $this->get($url, NULL, $headers); + } + catch(\Exception $e) { + return []; + } + + $output = []; + foreach($results["dataStores"] ?? [] as $dataStore) { + $dataStoreName = explode("/", $dataStore["name"]); + $dataStoreId = array_pop($dataStoreName); + $output[$dataStoreId] = $dataStore['displayName']; + } + return $output; + } + + /** + * @inheritDoc + */ + public function availableEngines(?string $service_account, ?string $project_id): array { + // Get token. + $settings = $this->settings[$this->id()]; + + if (!empty($service_account) && $service_account != "default") { + $settings['service_account'] = $service_account; + } + if (!empty($project_id) && $project_id != "default") { + $settings['project_id'] = $project_id; + } + + try { + $headers = [ + "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer") + ]; + } + catch (Exception $e) { + $this->error = $e->getMessage() ?? "Error getting access token."; + return []; + } + + $url = GcGenerationURL::build(GcGenerationURL::ENGINE, $settings); + + // Query the AI. + $output = []; + try { + $results = $this->get($url, NULL, $headers); + } + catch(\Exception $e) {} + + foreach($results["engines"] ?: [] as $engine) { + $engineName = explode("/", $engine["name"]); + $engineId = array_pop($engineName); + $output[$engineId] = $engine['displayName']; + } + + return $output; + + } + + public function availableProjects(?string $service_account): array { + + if (!empty($service_account) && $service_account != "default") { + $settings['service_account'] = $service_account; + } + + // TODO: For this to work the service account needs resourcemanager.projects.list + // permission on the organization. Right now, this has not been granted. + return [ + "738313172788" => "ai-search-boston-gov-91793", + "612042612588" => "vertex-ai-poc-406419", + ]; + + // Get token. + try { + $headers = [ + "Authorization" => $this->authenticator->getAccessToken($this->settings[$this->id()]['service_account'], "Bearer"), + "Accept" => "application/json", + ]; + } + catch (Exception $e) { + $this->error = $e->getMessage() ?? "Error getting access token."; + return []; + } + + $url = GcGenerationURL::build(GcGenerationURL::PROJECT, $this->settings[$this->id()]); + + // Query the AI. + $output = []; + $post_fields = NULL; + $post_fields = "parent=" . urlencode("organizations/593266943271"); +// $post_fields = [ +// "scope" => urlencode("organizations/593266943271"), +// "assetTypes" => ["cloudresourcemanager.googleapis.com/Project"] +// ]; + $results = $this->get($url, $post_fields, $headers); + foreach($results["dataStores"] ?: [] as $dataStore) { + $dataStoreName = explode("/", $results["dataStores"][0]["name"]); + $dataStoreId = array_pop($dataStoreName); + // $output[$dataStoreId] = $dataStore['displayName']; + $output[$dataStoreId] = $dataStoreId; + } + return $output; + + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcGeocoder.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcGeocoder.php index a0284d0d4e..56c9f57167 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcGeocoder.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcGeocoder.php @@ -385,7 +385,7 @@ private function parseGoogleAddress(array $result): array { * * @return array */ - public static function ajaxTestService(array $form, FormStateInterface $form_state): array { + public static function ajaxTestService(array &$form, FormStateInterface $form_state): array { $address = new BosGeoAddress(); $address->setSingleLineAddress("1 Cityhall plaza, Boston, MA"); @@ -449,4 +449,27 @@ public function setServiceAccount(string $service_account): GcServiceInterface { throw new Exception("There is no service account conmcept for geocoder"); } + /** + * @inheritDoc + */ + public function hasFollowup(): bool { + // Not applicable + return FALSE; + } + + /** + * @inheritDoc + */ + public function getSettings(): array { + return $this->settings[$this->id()]; + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + // Not implemented + return []; + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php index 1bd0b240b2..6e5a55b70a 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php @@ -4,6 +4,8 @@ use Drupal; use Drupal\bos_core\Controllers\Curl\BosCurlControllerBase; +use Drupal\bos_google_cloud\Apis\v1alpha\SearchResponse; +use Drupal\bos_google_cloud\GcGenerationPrompt; use Drupal\bos_google_cloud\GcGenerationURL; use Drupal\bos_google_cloud\GcGenerationPayload; use Drupal\Core\Config\ConfigFactory; @@ -16,16 +18,22 @@ use Exception; /** - class GcSearch - Creates a gen-ai search service for bos_google_cloud + * Class GcSearch. + * + * Creates a gen-ai search service for bos_google_cloud - uses Discovery Engine + * API to access Vertex Agent Builder apps, engines and datastores. + * + * david 01 2024 + * + * @extends BosCurlControllerBase + * @implements GcServiceInterface, GcAgentBuilderInterface + * + * @file docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php + * @see https://cloud.google.com/generative-ai-app-builder/docs/introduction + */ +class GcSearch extends BosCurlControllerBase implements GcServiceInterface, GcAgentBuilderInterface { - david 01 2024 - @file docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcSearch.php -*/ - -class GcSearch extends BosCurlControllerBase implements GcServiceInterface { - - /** + /** * Logger object for class. * * @var \Drupal\Core\Logger\LoggerChannelInterface @@ -94,13 +102,31 @@ public function setServiceAccount(string $service_account):GcSearch { * @return string * @throws \Exception */ - public function execute(array $parameters = []): string { + public function execute(array $parameters = []): FALSE|SearchResponse { + + // Verify the minimum information is available. + $this->validateQueryParameters($parameters); + if ($this->error()) { + return $this->error(); + } + + // Check quota + if (GcGenerationURL::quota_exceeded(GcGenerationURL::SEARCH)) { + $this->error = "Quota exceeded for Discovery API"; + return $this->error; + } + + // Manage conversations. + $allow_conversation = ($this->settings[$this->id()]["allow_conversation"] ?? FALSE && $parameters["allow_conversation"] ?? FALSE); - $settings = $this->settings["search"] ?? []; + // If we have overrides for the default projects or datastores, apply the + // override here. + $this->overrideModelSettings($parameters); + // Get new or cached OAuth2 authorization from GC. try { $headers = [ - "Authorization" => $this->authenticator->getAccessToken($settings["service_account"], "Bearer") + "Authorization" => $this->authenticator->getAccessToken($this->settings[$this->id()]["service_account"], "Bearer") ]; } catch (Exception $e) { @@ -108,23 +134,12 @@ public function execute(array $parameters = []): string { return $this->error(); } - if (empty($parameters["search"])) { - $this->error = "A search request is required."; - return $this->error(); - } - - $parameters["prompt"] = $parameters["prompt"] ?? "default"; - $parameters["text"] = $parameters["search"]; - - if (GcGenerationURL::quota_exceeded(GcGenerationURL::CONVERSATION)) { - $this->error = "Quota exceeded for this API"; - return $this->error; - } - - $url = GcGenerationURL::build(GcGenerationURL::CONVERSATION, $settings); + // Build the endpoint. + $url = GcGenerationURL::build(GcGenerationURL::SEARCH, $this->settings[$this->id()]); + // Build the payload (:search). try { - if (!$payload = GcGenerationPayload::build(GcGenerationPayload::CONVERSATION, $parameters)) { + if (!$payload = GcGenerationPayload::build(GcGenerationPayload::SEARCH, $parameters)) { $this->error = "Could not build Payload"; return $this->error; } @@ -134,67 +149,94 @@ public function execute(array $parameters = []): string { return $this->error; } + // Run the Query. $results = $this->post($url, $payload, $headers); - if ($this->http_code() == 200 && !$this->error()) { - - $this->response["search"] = []; - - $this->response["search"]["results"] = $results["reply"]["summary"]["summaryWithMetadata"]; - $this->response["search"]["conversation"] = $results["conversation"]; - $this->response["search"]["results"]["webpages"] = $results["searchResults"]; - $this->loadSafetyRatings($results["reply"]["summary"]["safetyAttributes"]); - unset($this->response["body"]); - - if (empty($this->response["search"]["results"]) || $this->error()) { - $this->error() || $this->error = "Unexpected response from GcSearch"; - return $this->error(); - } - - return $this->response["search"]["results"]["summary"]; - - } - - elseif ($this->http_code() == 401) { + if ($this->http_code() == 401) { // The token is invalid, because we are caching for the lifetime of the // token, this probably means it has been refreshed elsewhere. - $this->authenticator->invalidateAuthToken($settings["service_account"]); + $this->authenticator->invalidateAuthToken($this->settings[$this->id()]["service_account"]); if (empty($parameters["invalid-retry"])) { $parameters["invalid-retry"] = 1; return $this->execute($parameters); } - return ""; + throw new Exception($this->error); } - elseif ($this->error()) { - return ""; + elseif (empty($results) || $this->error() || $this->http_code() != 200) { + if (empty($this->error)) {$this->error = " Unknown Error: ";} + $this->error .= ", HTTP-CODE: " . $this->response["http_code"]; + throw new Exception($this->error); } - else { - $this->error = "Unknown Error: " . $this->response["http_code"]; - return ""; + // We got some sort of response, so load it into the SearchResponse obejct, + // verify it and then remove the "body" element because it is no longer + // needed. + $this->response["object"] = new SearchResponse($results); + if (!$this->response["object"]->validate()) { + $this->error() || $this->error = "Unexpected response from GcSearch"; + return $this->error(); } + unset($this->response["body"]); + + if ($allow_conversation) { + + /* When we built the initial Payload, the $allow_conversation = TRUE + caused the query to be set up for follow-up questions (by creating a + session). + The SearchResponse will have returned search results and session info. + Now we need to use the sessioninfo get a generated answer with a call + to projects.locations.collections.engines.servingconfigs.answer */ + + // Fetch the sessionid (and queryid) from the response. + $session_id = explode("/", $results["sessionInfo"]["name"]); + $session_id = array_pop($session_id); + $parameters["session_id"] = $session_id; + $query_id = explode("/", $results["sessionInfo"]["queryId"]); + $query_id = array_pop($query_id); + $parameters["query_id"] = $query_id; + + // Save the search request and response objects for later. + // (Calling the post method creates new request & response objects, + // overwriting what we currently have.) + $this->response["session_id"] = $session_id; + $this->response["query_id"] = $query_id; + $searchResponse = $this->response; + $this->searchRequest = $this->request; + + // Build the endpoint. + $url = GcGenerationURL::build(GcGenerationURL::SEARCH_ANSWER, $this->settings[$this->id()]); + + // Build the payload (:answer). + try { + if (!$payload = GcGenerationPayload::build(GcGenerationPayload::SEARCH_ANSWER, $parameters)) { + $this->error = "Could not build Payload"; + return $this->error; + } + } + catch (Exception $e) { + $this->error = $e->getMessage(); + return $this->error; + } - } + // Run the second query. + $results = $this->post($url, $payload, $headers); - /** - * Update the $this->response["search"]["ratings"] array if the safety scores - * in $ratings are higher (less safe) than those already stored. - * - * @param array $ratings The safetyRatings from a gemini ::predict call. - * - * @return void - */ - private function loadSafetyRatings(array $ratings): void { + if (!$results) { + throw new \Exception($this->error); + } - if (!isset($this->response["search"]["safetyRatings"])) { - $this->response["search"]["safetyRatings"] = []; - } + // Merge the Answer Results into the Search Results + $this->mergeResults($searchResponse, $results); + $this->response = $searchResponse; - foreach($ratings["categories"] as $key => $rating) { - $this->response["search"]["safetyRatings"][$rating] = $ratings["scores"][$key]; } + // Gather Vertex search metadata. + $this->loadMetadata($parameters); + + return $this->response["object"]; + } /** @@ -203,9 +245,11 @@ private function loadSafetyRatings(array $ratings): void { public function buildForm(array &$form): void { $project_id="612042612588"; - $model_id="drupalwebsite_1702919119768"; + $datastore_id="drupalwebsite_1702919119768"; + $engine_id="oeoi-search-pilot_1726266124376"; $location_id="global"; $endpoint="https://discoveryengine.googleapis.com"; + $model="stable"; $svs_accounts = []; foreach ($this->settings["auth"]??[] as $name => $value) { @@ -214,7 +258,7 @@ public function buildForm(array &$form): void { } } - $settings = $this->settings['search'] ?? []; + $settings = $this->settings[$this->id()] ?? []; $form = $form + [ 'search' => [ @@ -236,10 +280,20 @@ public function buildForm(array &$form): void { '#type' => 'textfield', '#title' => t('Data Store'), '#description' => t(''), - '#default_value' => $settings['datastore_id'] ?? $model_id, + '#default_value' => $settings['datastore_id'] ?? $datastore_id, + '#required' => TRUE, + '#attributes' => [ + "placeholder" => 'e.g. ' . $datastore_id, + ], + ], + 'engine_id' => [ + '#type' => 'textfield', + '#title' => t('Engine'), + '#description' => t(''), + '#default_value' => $settings['engine_id'] ?? $engine_id, '#required' => TRUE, '#attributes' => [ - "placeholder" => 'e.g. ' . $model_id, + "placeholder" => 'e.g. ' . $engine_id, ], ], 'location_id' => [ @@ -263,6 +317,17 @@ public function buildForm(array &$form): void { "placeholder" => 'e.g. ' . $endpoint, ], ], + 'model' => [ + '#type' => 'select', + '#title' => t('The LLM model to use'), + '#description' => t('This is the model that will be used.
Best to set to "stable" for latest stable release (which typically is frozen and only updated periodically) or "preview" for the latest model (which is more experimental and can be updated more frequently).
See https://cloud.google.com/generative-ai-app-builder/docs/answer-generation-models#models'), + '#default_value' => $settings['model'] ?? $model, + '#options' => [ + 'stable' => 'Stable', + 'preview' => 'Preview', + ], + '#required' => TRUE, + ], 'service_account' => [ '#type' => 'select', '#title' => t('The default service account to use'), @@ -274,6 +339,13 @@ public function buildForm(array &$form): void { "placeholder" => 'e.g. ' . ($svs_accounts[0] ?? "No Service Accounts!"), ], ], + 'allow_conversation' => [ + '#type' => 'checkbox', + '#title' => t('Allow conversations to continue.'), + '#description' => t('If this option is de-selected, previous questions and answers are not considered for context.'), + '#default_value' => $settings['allow_conversation'] ?? 0, + '#required' => FALSE, + ], 'test_wrapper' => [ 'test_button' => [ '#type' => 'button', @@ -309,13 +381,19 @@ public function submitForm(array $form, FormStateInterface $form_state): void { if ($config->get("search.project_id") !== $values['project_id'] ||$config->get("search.datastore_id") !== $values['datastore_id'] + ||$config->get("search.engine_id") !== $values['engine_id'] ||$config->get("search.location_id") !== $values['location_id'] ||$config->get("search.service_account") !== $values['service_account'] - ||$config->get("search.endpoint") !== $values['endpoint']) { + ||$config->get("search.allow_conversation") !== $values['allow_conversation'] + ||$config->get("search.endpoint") !== $values['endpoint'] + ||$config->get("search.model") !== $values['model']) { $config->set("search.project_id", $values['project_id']) ->set("search.datastore_id", $values['datastore_id']) + ->set("search.engine_id", $values['engine_id']) ->set("search.location_id", $values['location_id']) + ->set("search.allow_conversation", $values['allow_conversation']) ->set("search.endpoint", $values['endpoint']) + ->set("search.model", $values['model']) ->set("search.service_account", $values['service_account']) ->save(); } @@ -360,4 +438,369 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st } + /** + * @inheritDoc + */ + public function hasFollowup(): bool { + return TRUE; + } + + /** + * @inheritDoc + */ + public function getSettings(): array { + return $this->settings[$this->id()]; + } + + /** + * @inheritDoc + */ + public function loadMetadata(array $parameters): void { + + if (!$parameters["metadata"]) { + return; + } + + $service_account = $this->settings[$this->id()]["service_account"]; + + // Populate the metadata with everything. + $this->response["metadata"] = [ + "Model" => array_merge($this->settings[$this->id()], [ + $service_account => [ + "client_id" => $this->settings["auth"][$service_account]["client_id"], + "client_email" => $this->settings["auth"][$service_account]["client_email"], + "project_id" => $this->settings["auth"][$service_account]["project_id"], + ] + ]), + "Search Presets" => [], + "Search Query Request" => NULL, + "Summary Query Request" => $this->request(), + "Response" => $this->response(), + ]; + if (property_exists($this, "searchRequest")){ + $this->response["metadata"]["Search Query Request"] = $this->searchRequest; + } + else { + unset($this->response["metadata"]["Search Query Request"]); + } + + // Flatten the SearchResponse object + $this->response["metadata"]["Response"]["SearchResponse"] = $this->response["metadata"]["Response"]["object"]->toArray(); + + // Remove elements we don't need. + unset($this->response["metadata"]["Response"]["object"]); + unset($this->response["metadata"]["Response"]["metadata"]); + + } + + /** + * @param string|null $service_account * + * + * @inheritDoc + */ + public function availableProjects(?string $service_account): array { + // Todo: adjust permissions in GC so we can scan for projects, then + // only return projects which have agent builder enabled. + if (!empty($service_account) && $service_account != "default") { + $settings['service_account'] = $service_account; + } + return [ + "738313172788" => "ai-search-boston-gov-91793", + "612042612588" => "vertex-ai-poc-406419", + ]; + } + + /** + * @inheritDoc + */ + public function availableDataStores(?string $service_account, ?string $project_id): array { + + $settings = $this->settings[$this->id()]; + + if (!empty($service_account) && $service_account != "default") { + $settings['service_account'] = $service_account; + } + if (!empty($project_id) && $project_id != "default") { + $settings['project_id'] = $project_id; + } + + // Get token. + try { + $headers = [ + "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer") + ]; + } + catch (Exception $e) { + $this->error = $e->getMessage() ?? "Error getting access token."; + return []; + } + + $url = GcGenerationURL::build(GcGenerationURL::DATASTORE, $settings); + + // Query the AI. + try { + $results = $this->get($url, NULL, $headers); + } + catch(\Exception $e) { + return []; + } + + $output = []; + foreach($results["dataStores"] ?? [] as $dataStore) { + $dataStoreName = explode("/", $dataStore["name"]); + $dataStoreId = array_pop($dataStoreName); + $output[$dataStoreId] = $dataStore['displayName']; + } + return $output; + } + + /** + * @inheritDoc + */ + public function availableEngines(?string $service_account, ?string $project_id): array { + // Get token. + $settings = $this->settings[$this->id()]; + + if (!empty($service_account) && $service_account != "default") { + $settings['service_account'] = $service_account; + } + if (!empty($project_id) && $project_id != "default") { + $settings['project_id'] = $project_id; + } + + try { + $headers = [ + "Authorization" => $this->authenticator->getAccessToken($settings['service_account'], "Bearer") + ]; + } + catch (Exception $e) { + $this->error = $e->getMessage() ?? "Error getting access token."; + return []; + } + + $url = GcGenerationURL::build(GcGenerationURL::ENGINE, $settings); + + // Query the AI. + $output = []; + try { + $results = $this->get($url, NULL, $headers); + } + catch(\Exception $e) {} + + foreach($results["engines"] ?: [] as $engine) { + $engineName = explode("/", $engine["name"]); + $engineId = array_pop($engineName); + $output[$engineId] = $engine['displayName']; + } + + return $output; + + } + + /** + * @inheritDoc + */ + public function availableApps(?string $service_account, ?string $project_id): array { + return $this->availableDataStores($service_account, $project_id); + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + return GcGenerationPrompt::getPrompts($this->id()); + } + + /** + * Returns the current session info (if any). + * @return array + */ + public function getSessionInfo(): array { + return [ + "query_id" => $this->response["query_id"] ?: NULL, + "session_id" => $this->response["session_id"] ?: NULL, + ]; + } + + /******************************************** + * Helper Functions + ********************************************/ + + /** + * Make an initial check on the parameters array contents. + * + * @param array $parameters + * + * @return bool|string|void|null + * @throws \Exception + */ + + private function validateQueryParameters(array &$parameters) { + + if (empty($parameters["text"])) { + $this->error = "A search request is required."; + } + elseif (empty($this->settings[$this->id()])) { + $this->error = "The conversation API settings are empty or missing."; + } + + // ensure these parameters have a default setting. + $parameters["prompt"] = $parameters["prompt"] ?? "default"; + $parameters["model"] = $this->settings[$this->id()]["model"] ?? "stable"; + + } + + /** + * Override the model settings with values from parameters["overrides"]. + * + * Copy the svs_settings into parameters array. + * + * @param array $parameters + * + * @return void After this method, the svs_settings and parameters should be + * synchronized. + */ + private function overrideModelSettings(array &$parameters): void { + + if (!empty($parameters["service_account"])) { + $this->settings[$this->id()]['service_account'] = $parameters["service_account"]; + } + else { + $parameters["service_account"] = $this->settings[$this->id()]['service_account']; + } + + if (!empty($parameters["project_id"])) { + $this->settings[$this->id()]['project_id'] = $parameters["project_id"]; + } + else { + $parameters["project_id"] = $this->settings[$this->id()]['project_id']; + } + + if (!empty($parameters["datastore_id"])) { + $this->settings[$this->id()]['datastore_id'] = $parameters["datastore_id"]; + } + else { + $parameters["datastore_id"] = $this->settings[$this->id()]['datastore_id']; + } + + if (!empty($parameters["engine_id"])) { + $this->settings[$this->id()]['engine_id'] = $parameters["engine_id"]; + } + else { + $parameters["engine_id"] = $this->settings[$this->id()]['engine_id']; + } + + } + + /** + * Update the $this->response["search"]["ratings"] array if the safety scores + * in $ratings are higher (less safe) than those already stored. + * + * @param array $ratings The safetyRatings from a gemini ::predict call. + * + * @return void + */ + private function loadSafetyRatings(array $ratings): void { + + if (!isset($this->response["search"]["safetyRatings"])) { + $this->response["search"]["safetyRatings"] = []; + } + + foreach($ratings["categories"] ?? [] as $key => $rating) { + $this->response["search"]["safetyRatings"][$rating] = $ratings["scores"][$key]; + } + + } + + /** + * Merge an AnswerResponseObject into a ResponseObject + * + * @param $results + * + * @return void + */ + private function mergeResults(array &$searchResponse, array $results): void { + // Merge these results/response into the original response. + $searchResponse["object"]->set("summary", [ + "summaryText" => $results["answer"]["answerText"], // Summary with citations + "safetyAttributes" => [""], + "summaryWithMetadata" => [ + "summary" => $results["answer"]["answerText"], // Summary with no citations + "citationMetadata" => [ + "citations" => $this->reformatCitations($results["answer"]["citations"] ?? []), + ], + "references" => $this->reformatReferences($results["answer"]["references"] ?? []), + ], + "extraInfo" => [ + "queryUnderstandingInfo" => $results["answer"]["queryUnderstandingInfo"] ?? NULL, + "answerName" => $results["answer"]["name"], + "steps" => $results["answer"]["steps"], + "state" => $results["answer"]["state"], + "createTime" => $results["answer"]["createTime"] ?? NULL, + "completeTime" => $results["answer"]["completeTime"] ?? NULL, + "answerSkippedReasons" => $results["answer"]["answerSkippedReasons"] ?? NULL, + ] + ]); + $searchResponse["object"]->set("guidedSearchResult", [ + "refinementAttributes" => NULL, + "followUpQuestions" => $results["answer"]["relatedQuestions"] ?? NULL, + ]); + $searchResponse["object"]->set("sessionInfo", array_merge($searchResponse["object"]->get("sessionInfo"), $results["session"])); + + // Manage the response object. + $searchResponse["elapsedTime"] += $this->response["elapsedTime"]; + $searchResponse["http_code"] = $this->response["http_code"]; + $searchResponse["answer_response_raw"] = $this->response["response_raw"]; + $searchResponse["metadata"] = NULL; + + } + + /** + * Reformats the citations in AnswerQueryResponse to the SearchResponse format. + * + * @param $answerCitations + * + * @return array + */ + private function reformatCitations($answerCitations): array { + $output = []; + foreach($answerCitations as $k => $citation) { + foreach( $citation["sources"] as $key => $source) { + $sources[$key] = ["referenceIndex" => $source["referenceId"]]; + } + $output[$k] = [ + "startIndex" => $citation["startIndex"] ?? 0, + "endIndex" => $citation["endIndex"], + "sources" => $sources, + ]; + } + return $output; + } + + /** + * Reformats the citations in AnswerQueryResponse to the SearchResponse format. + * + * @param $answerCitations + * + * @return array + */ + private function reformatReferences($answerReferences): array { + $output = []; + foreach($answerReferences as $reference) { + $output[] = [ + "title" => $reference["chunkInfo"]["documentMetadata"]["title"], + "document" => $reference["chunkInfo"]["documentMetadata"]["document"], + "uri" => $reference["chunkInfo"]["documentMetadata"]["uri"], + "chunkContents" => [ + "content" => $reference["chunkInfo"]["content"], + "pageIdentifier" => NULL, + ], + "extraInfo" => [ + "relevanceScore" => $reference["chunkInfo"]["relevanceScore"] ?: NULL, + ], + ]; + } + return $output; + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcServiceInterface.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcServiceInterface.php index 239743f368..74ca188cde 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcServiceInterface.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcServiceInterface.php @@ -4,6 +4,11 @@ use Drupal\Core\Form\FormStateInterface; +/** + * Interface GcServiceInterface. + * + * Provides methods to interact with a Google Cloud service. + */ interface GcServiceInterface { /** @@ -18,14 +23,14 @@ public static function id(): string; * * @params string $parameters An array of parameters for this service. * - * @return string The output from the service. + * @return string|mixed The output from the service. * * @description Typically: * $parameters["text"] - The text string to process * $parameters["prompt"] - The prompt to use during processing * */ - public function execute(array $parameters = []): string; + public function execute(array $parameters = []): object|string|FALSE; /** * Build the section on the Goggle Cloud Confrm form for this service. @@ -79,4 +84,35 @@ public function error(): string|bool; */ public function setServiceAccount(string $service_account):GcServiceInterface; + /** + * Flag whether the service supports an ongoing conversation. + * + * @return bool TRUE is conversation supported. + */ + public function hasFollowup():bool; + + /** + * Return the Google Cloud config for this service. + * + * @return array + */ + public function getSettings():array; + + /** + * Provides a means to test connectivity to this service. Used by the config form. + * + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + * + * @return array Render array for forms API - message back based on test result. + */ + public static function ajaxTestService(array &$form, FormStateInterface $form_state): array; + + /** + * Returns a list of prompts which can be used by this service. + * + * @return array + */ + public function availablePrompts(): array ; + } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextRewriter.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextRewriter.php index eb8938bccf..96353bd2af 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextRewriter.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextRewriter.php @@ -475,4 +475,24 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st } + /** + * @inheritDoc + */ + public function hasFollowup(): bool { + return FALSE; + } + /** + * @inheritDoc + */ + public function getSettings(): array { + return $this->settings[$this->id()]; + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + return GcGenerationPrompt::getPrompts($this->id()); + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextSummarizer.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextSummarizer.php index 2c07f3cf18..d81ee5a8d8 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextSummarizer.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTextSummarizer.php @@ -502,4 +502,25 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st } + /** + * @inheritDoc + */ + public function hasFollowup(): bool { + return FALSE; + } + + /** + * @inheritDoc + */ + public function getSettings(): array { + return $this->settings[$this->id()]; + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + return GcGenerationPrompt::getPrompts($this->id()); + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTranslation.php b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTranslation.php index 965a4f913f..6ac69c0526 100644 --- a/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTranslation.php +++ b/docroot/modules/custom/bos_components/modules/bos_google_cloud/src/Services/GcTranslation.php @@ -479,4 +479,25 @@ public static function ajaxTestService(array &$form, FormStateInterface $form_st } + /** + * @inheritDoc + */ + public function hasFollowup(): bool { + return FALSE; + } + + /** + * @inheritDoc + */ + public function getSettings(): array { + return $this->settings[$this->id()]; + } + + /** + * @inheritDoc + */ + public function availablePrompts(): array { + return GcGenerationPrompt::getPrompts($this->id()); + } + } diff --git a/docroot/modules/custom/bos_components/modules/bos_search/README.md b/docroot/modules/custom/bos_components/modules/bos_search/README.md new file mode 100644 index 0000000000..cbb2b1ece9 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/README.md @@ -0,0 +1,39 @@ +# AI-Enabled Search +This component has several elements; +- a configuration form, +- a button, +- a modal form, and +- an integration framework to connect to AI services/models. + +## Button +The button is an html element to allow the user to launch the modal form. +The button is located somewhere on the page using either +- a **snippet** (so it can be added to the top menu), or +- a **paragraph** (so it can be added as a page or sidebar component). + +The button is permission-aware so the user-group who can access the button can be controlled. + +The button has some configuration, so that the AIEngine preset to be used can be selected when embedding the button. + +Two buttons on the same page using different presets allows direct comparision of AI models and settings. + +## Search Form +The modal Search Form is where the search is performed. + +The Search Form is an AJAX driven form which is deployed as a block, and should be added to the bottom of pages where AI-enabled search is desired. + +The Search Form +- can only be launched from the button, +- provides a conversation-based search experience, +- remembers previous searches performed by the user and "picks-up" and continues the conversation from earlier in the session, and +- is AI Model agnostic. + +## Configuration Form +The Configuration Form allows the administrator to set various behiavors for the Search Form. + +The Configuration Form also configures **_Presets_** which are "designers" for the various AI Model integrations. +A button must define a single preset -and it passes the preset-info to the Search Form. + +## Intergration with AI Models +Standard interfaces are implemented. Custom Drupal AI Model modules which implement these interfaces can send and receive instructions and results with the Search Form (e.g. _bos_google_cloud::GcSearch_). +This way we can add as many AI Models as we desire hopefully without the need to alter the Search Form, or the component Configuration Form. diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.info.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.info.yml new file mode 100644 index 0000000000..961cb21481 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.info.yml @@ -0,0 +1,8 @@ +name: 'AI-enabled Search' +type: module +description: 'AI-enabled Search front end component for boston.gov.' +core_version_requirement: ^10 +package: 'Custom' +dependencies: + - bos_google_cloud +config_devel: { } diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.libraries.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.libraries.yml new file mode 100644 index 0000000000..1aa9422402 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.libraries.yml @@ -0,0 +1,87 @@ +core: + css: + theme: + css/bos_search.css: {} + js: + js/bos_search.js: {} + dependencies: + - core/drupal + - core/jquery + - core/once + +component.search_bar: + css: + theme: + css/search_bar.css: { } + +component.card: + css: + theme: + css/card.css: { } + +component.quote_card: + css: + theme: + css/quote_card.css: { } + +component.grid_of_cards: + css: + theme: + css/grid_of_cards.css: { } + +snippet.modal_close: + css: + theme: + css/modal_close.css: { } + js: + js/modal_close.js: {} + dependencies: + - core/drupal + - core/jquery + - core/once + +snippet.search_feedback: + css: + theme: + css/ai_feedback.css: { } + js: + js/ai_feedback.js: {} + dependencies: + - core/drupal + - core/jquery + - core/once + - core/drupal.ajax + - webform/webform.ajax + +snippet.search_button: + css: + theme: + css/ai_searchbutton.css: { } + js: + js/ai_searchbutton.js: {} + dependencies: + - core/drupal + - core/jquery + - core/once + +dynamic-loader: + js: + js/dynamic_loader.js: { } + dependencies: + - core/drupal + - core/jquery + - core/once + +disclaimer: + version: 1.0 + css: + theme: + css/disclaimer.css: { } + js: + js/disclaimer.js: {} + dependencies: + - core/drupal + - core/drupalSettings + - core/jquery + - core/drupal.dialog + - core/drupal.dialog.ajax diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.menu.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.menu.yml new file mode 100644 index 0000000000..6ac768423d --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.menu.yml @@ -0,0 +1,6 @@ +bos_search.AiSearchConfigForm: + title: 'AI-enabled Search' + description: 'Configure AI-enabled Search for boston.gov.' + parent: bos_core.admin + route_name: bos_search.AiSearchConfigForm + weight: 0 diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.task.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.task.yml new file mode 100644 index 0000000000..f6cc1dd88b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.links.task.yml @@ -0,0 +1,5 @@ +bos_search.AiSearchConfigForm: + title: 'AI-enabled Search' + route_name: bos_search.AiSearchConfigForm + base_route: bos_core.admin + weight: 2 diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.module b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.module new file mode 100644 index 0000000000..d5d9af9a4f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.module @@ -0,0 +1,67 @@ + [ + 'base hook' => 'form', + 'template' => 'form-element--webform-checkbox', + ], + 'aisearch_button' => [ + 'template' => 'snippets/aisearch-button', + 'variables' => [ + 'search_form_url' => '/search', + 'button_title' => '', + 'button_css' => '', + 'preset' => '', + 'preset_theme' => '', + 'display' => '', + 'body' => '', + ], + ], + + ]; + + // Discover dynamic aisearch templates and themes from this module + _bos_search_autodiscover_theme($output); + _bos_search_snippet_theme($output); + + // Load component themes. + // TODO: This should be moved to the themes hook_theme function. + _load_component_definitions($output); + + return $output; + +} + +function bos_search_theme_suggestions_alter(array &$suggestions, array &$variables, $hook):void { + if (AiSearch::isBosSearchThemed()) { + _search_form_suggestions($suggestions, $variables, $hook); + } + if ($hook == "form_element" && in_array("form_element__webform_checkbox",$suggestions)) { + $suggestions[] = "form_element__bos_search__disclaimer__webform_checkbox"; + } +} + +function bos_search_preprocess_search_bar(&$variables):void { + _bos_search_preprocess_search_bar($variables); +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.permissions.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.permissions.yml new file mode 100644 index 0000000000..2e7b2ae1b1 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.permissions.yml @@ -0,0 +1,9 @@ +'view ai-enabled search permission': + title: 'View AI-Enabled Search Component' + description: 'Use the AI-enabled search to search the site content.' + restrict access: false + +'administer ai-enabled search permission': + title: 'Administer AI-Enabled Search Component' + description: 'Administer the AI-enabled search component.' + restrict access: true diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.routing.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.routing.yml new file mode 100644 index 0000000000..cd4d1e3f32 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.routing.yml @@ -0,0 +1,35 @@ +bos_search.AiSearchConfigForm: + path: 'admin/config/system/boston/aisearch' + defaults: + _form: '\Drupal\bos_search\Form\AiSearchConfigForm' + _title: 'Search Configuration Form' + requirements: + _permission: 'administer ai-enabled search permission' + +bos_search.open_AISearchForm: + path: 'admin/config/system/boston/aisearch/AiSearchForm' + defaults: + _controller: '\Drupal\bos_search\Controller\AiSearchFormController::openModalForm' + _title: 'Search boston.gov' + requirements: + _permission: 'view ai-enabled search permission' + options: + _admin_route: TRUE + +bos_search.open_DisclaimerForm: + path: 'admin/config/system/boston/aisearch/AiDisclaimerForm' + defaults: + _controller: '\Drupal\bos_search\Controller\AiSearchFormController::openDisclaimerForm' + _title: 'AI Disclaimer' + requirements: + _permission: 'view ai-enabled search permission' + options: + _admin_route: TRUE + +bos_search.autocomplete_nodes: + path: '/bos_search_autocomplete_nodes' + defaults: + _controller: '\Drupal\bos_search\Controller\AutocompleteController::searchNodes' + _title: 'Autocomplete' + requirements: + _access: 'TRUE' diff --git a/docroot/modules/custom/bos_components/modules/bos_search/bos_search.services.yml b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.services.yml new file mode 100644 index 0000000000..6075979253 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/bos_search.services.yml @@ -0,0 +1,11 @@ +services: + bos_search.callbacks: + class: Drupal\bos_search\AiSearchFormCallbacks + arguments: ['@entity_type.manager', '@form_builder'] + + plugin.manager.aisearch: + class: Drupal\bos_search\Plugin\AiSearch\AiSearchPluginManager + parent: default_plugin_manager + + Drupal\bos_search\Twig\CustomFiltersExtension: + tags: ['twig.extension'] diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/ai_feedback.css b/docroot/modules/custom/bos_components/modules/bos_search/css/ai_feedback.css new file mode 100644 index 0000000000..e6678aeb49 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/ai_feedback.css @@ -0,0 +1,222 @@ +.ai-feedback-wrapper { + color: #58585B; +} +.ai-feedback-buttons { + display: flex; + gap: 16px; + align-items: center; + padding: 16px 16px 16px 8px; + border: 1px solid #C4C1C1; +} +.ai-feedback-buttons .thumbsdown, +.ai-feedback-buttons .thumbsup { + max-height: 20px; +} +.ai-feedback-item.speaker { + margin-right:32px; +} +.ai-feedback-item { + cursor: pointer; + line-height: 32px; + padding:8px; +} +.ai-feedback-item .ai-feedback-svg { + fill: #091f2f; +} +.ai-feedback-item:active .ai-feedback-svg { + fill: #FFFFFF; +} +.ai-feedback-item:focus .ai-feedback-svg, +.ai-feedback-item:hover .ai-feedback-svg { + fill: #288BE4; +} +.ai-feedback-item:hover:not(:disabled), .button:focus:not(:disabled), .button.ai-feedback-item { + background-color: transparent; +} +.ai-feedback-item:active { + background-color: #288be4; +} +.ai-feedback-text {display: block;} +.ai-feedback-confirm { + display: none; + font-family: 'Lora', 'Georgia', serif; + font-size: 1em; + line-height: 1.75em; + color: #58585B; + font-weight: 700; + background-color: rgb(40,139,228,0.10); + width: fit-content; + padding: 16px 32px 16px 16px; + border-radius: 4px; +} +.feedback-dialog .ui-dialog-titlebar { + background-image: none; + background-color: transparent; + border: none; +} +.feedback-dialog { + font-size: 16px; + line-height: 25px; + font-family: 'Lora', 'Georgia', serif; + letter-spacing: 0.5px; + color: #58585B; +} +.feedback-dialog #drupal-modal.ui-dialog-content { + padding: 0 16px; + margin-top: 32px; +} +.feedback-dialog .ui-dialog-title {display:none;} +.feedback-dialog .ui-dialog-titlebar-close { + border:none; + background-color: transparent; + width: 36px; + height: 36px; + margin: 4px; + position: absolute; + top: 0; + right: 0; +} +.feedback-dialog .ui-dialog-titlebar-close:active, +.feedback-dialog .ui-dialog-titlebar-close:hover { +} +.feedback-dialog .ui-icon-closethick, +.feedback-dialog .ui-icon-closethick:active, +.feedback-dialog .ui-icon-closethick:hover { + background-image: url(../img/x.svg); + background-position: 0; + background-size: 13px; +} +.feedback-dialog .ui-button:hover .ui-icon, +.feedback-dialog .ui-button:focus .ui-icon { + background-image: url(../img/x.svg); +} +.feedback-dialog .fieldgroup legend { + margin-bottom: 16px; +} +.feedback-dialog legend .fieldset-legend { + font-size: calc(1em + 2px); + line-height: calc((1em + 2px) * 1.5); + font-weight: 700; + font-family: Montserrat, Arial, sans-serif; + color: #091f2f; +} +.feedback-dialog .form-item.checkboxes--wrapper { + height: fit-content; +} +.feedback-dialog .form-type-checkbox { + margin: 0 12px 16px 0; + padding: 0; + max-height: 32px; +} +.feedback-dialog .form-type-checkbox.form-item label { + display: initial; + top: -9px; + line-height: 50px; + font-size: 1em; + text-transform: none; + font-weight: 400; + font-family: inherit; + color: #58585B; + position: relative; + letter-spacing: 0.5px; +} +.feedback-dialog .form-type-checkbox.form-item input[type=checkbox] { + display: initial; + width: 32px; + margin: 0 10px 0 0; + max-height: 32px; +} +.feedback-dialog .form-type-textarea.form-item label { + display: initial; + line-height: 50px; + font-size: calc(1em + 2px); + font-weight: 700; + font-family: Montserrat, Arial, sans-serif; + color: #091f2f; + text-transform: none; +} +.feedback-dialog .form-type-textarea.form-item textarea { + font-size: 1em; + line-height: 25px; + font-family: 'Lora', 'Georgia', serif; + letter-spacing: 0.5px; + padding: 8px; +} +.feedback-dialog .webform-element-help { + color: #ffffff; + background-color: #091f2f; + border-color: #091f2f; + font-weight: bold; + font-family: Montserrat, Arial, sans-serif; + position: relative; +} +.feedback-dialog .form-item .tippy-box { + background-color: #f6f6f6; +} +.feedback-dialog .form-item .tippy-box .tippy-content {} +.feedback-dialog .form-item .tippy-box .webform-element-help--title { + color: #288BE4; +} +.feedback-dialog .form-item .tippy-box .webform-element-help--content { + text-transform: none; + color: #58585B; +} +.feedback-dialog .text-count-message { + font-style: italic; + color: #58585B; + font-weight: 400; +} +.feedback-dialog .ui-dialog-buttonpane { + border: 0; + padding: 12px 16px; +} +.feedback-dialog .ui-dialog-buttonpane .form-actions {float: none;width: 100%;} +.feedback-dialog .ui-dialog-buttonpane .webform-button--submit { + width: 100%; + font-family: Montserrat, Arial, sans-serif; + letter-spacing: 2px; +} +.feedback-dialog .ui-dialog-buttonpane .webform-button--submit:focus, +.feedback-dialog .ui-dialog-buttonpane .webform-button--submit:hover, +.feedback-dialog .ui-dialog-buttonpane .webform-button--submit:active { + border: 0; + font-weight: bold; + background-color: #fb4d42; + color: #fff; +} +.feedback-dialog form .b--p300 {padding: 0;margin: 0;width: 100%;} +.feedback-dialog #system-messages .messages__icon {display:none;} +.feedback-dialog #system-messages {padding: 16px;margin-bottom: 20px;} +.feedback-dialog #system-messages .messages--item { + font-size: 1em; + line-height: 25px; + font-family: 'Lora', 'Georgia', serif; +} +.feedback-dialog #system-messages .message--button {position: absolute;right: 16px;top: 16px;} + +.feedback-dialog .webform-actions .ajax-progress { + display:none; +} + +/** + * DESKTOP adjustments + * This component considers mobile upto window width of 480px + */ +@media screen and (min-width: 480px) { + .ai-feedback-wrapper { + } + .feedback-dialog { + max-width: 410px; + width: 410px !important; + left: calc((100vw / 2) - 205px) !important; + } + .feedback-dialog #drupal-modal.ui-dialog-content { + padding: 0 42px; + } + .feedback-dialog .ui-dialog-buttonpane { + border: 0; + margin: 12px 0 12px 0; + padding: 0 42px; + } +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/ai_searchbutton.css b/docroot/modules/custom/bos_components/modules/bos_search/css/ai_searchbutton.css new file mode 100644 index 0000000000..218e6a2399 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/ai_searchbutton.css @@ -0,0 +1,47 @@ +.aienabledsearchbutton-wrapper { + font-family: 'Lora', 'Georgia', serif; + font-size: 16px; + line-height: calc(16px * 1.75); + color: #58585B; + font-weight: 400; + background-color: #f2f2f2; +} +.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button { + background-color: #288be4; + color: #ffffff; + max-width: fit-content; + position: relative; + padding-left: 47px; +} +.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:hover { background-color: #1376CF;} +.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:focus, +.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:active { background-color: #0062BB;} +.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:disabled { background-color: #d2d2d2; color: #58585b; } + +.aienabledsearchbutton-wrapper .aienabledsearchbutton a.button:before { + content: ""; + background-image: url(../img/twinkle_white.svg); + height: 24px; + width: 24px; + background-size: 24px; + background-repeat: no-repeat; + position: absolute; + top: 13px; + left: 12px; +} +.aienabledsearchbutton-wrapper .aienabledsearchbutton legend { + width: fit-content; + font-size: calc(1em + 2px); + max-width: 1000px; + margin: 0 0 16px 0; + padding : 0; + +} +/** + * DESKTOP adjustments + * This component considers mobile upto window width of 480px + */ +@media screen and (min-width: 480px) { + +} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/bos_search.css b/docroot/modules/custom/bos_components/modules/bos_search/css/bos_search.css new file mode 100644 index 0000000000..1da727822c --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/bos_search.css @@ -0,0 +1,528 @@ +/** + * OVERLAY + */ +.ui-widget-overlay.ui-front { + background-color: #1871bd; + opacity: .5; +} + +/*** + * MOBILE FIRST CSS. + */ + +/** + * GENERAL FORM SETTINGS + * .aienabledsearchform is the outermost dimension of the search form = container + * .aisearch-modal-form (on .aienabledsearchform) means in a modal dialog window + * Rules apply to all screen sizes with @media (min-width: 480px) denoting breakpoint for (responsive) desktop + */ +.aienabledsearchform { + font-family: 'Lora', 'Georgia', serif; + font-size: 16px; + line-height: 26px; + letter-spacing: 0.5px; + padding: 0; + margin: 0; + position: relative; +} +/** + * MODAL DIALOG WINDOW + */ +.aienabledsearchform.aisearch-modal-form { + left: 1% !important; + top: 5px !important; + width: 98% !important; + z-index: 550 !important; +} +#drupal-modal { + padding: 0; +} +.aisearch-modal-form.ui-dialog.ui-corner-all { + /* place border around the modal dialog */ + border-radius: 10px; + padding-bottom: 0; +} + +/** + * MODAL DIALOG TITLEBAR + */ +.aisearch-modal-form .ui-dialog-titlebar { +} +.aisearch-modal-form .ui-titlebar-hidden { + display:none; +} +.aisearch-modal-form .ui-dialog-titlebar .ui-dialog-title {} +.aisearch-modal-form .ui-dialog-titlebar .ui-dialog-titlebar-close {} +.aisearch-modal-form .ui-dialog-titlebar .ui-icon-closethick {} + +/** + * Set modal window button states + */ +.aisearch-modal-form .ai-form-reset { +} +.aisearch-modal-form .ai-reset-button { + background-color: rgba(40, 139, 228, 0.1); + border-radius: 10px +} +.aisearch-modal-form .ai-reset-button:hover { + background-color: rgba(40, 139, 228, 0.2); +} +.aisearch-modal-form .ai-reset-button:active { + background-color: rgba(40, 139, 228, 0.4); +} + +/** + * FORM MAIN CONTENT + */ +/** + * Cards + */ +#welcome-cards #edit-cards { + margin-top: 0; + font-size: 16px; +} +#welcome-cards #edit-cards .goc-wrapper { + margin-bottom: 16px; +} +#welcome-cards #edit-cards .goc-grid { + margin-top: 31px; +} +#welcome-cards #edit-cards .goc-title { + font-size: calc(1em - 2px); +} +/** + * Search Results area + */ +.bos-search-aisearchform #edit-searchresults {margin: 50px 0;} +.bos-search-aisearchform .search-results-outer-wrapper { + margin-top: 36px; + padding: 0 10px; +} +#drupal-modal.no-welcome #edit-welcome { + display:none; +} + +/** + * Original Question + */ +.bos-search-aisearchform .search-request-wrapper { + margin: 78px 0 24px 0; + width: 100%; +} +.bos-search-aisearchform .search-request { + background-color: rgba(24, 113, 189, 0.1); /* Optimistic Blue 10% */ + border: 1px solid rgba(24, 113, 189, 0.7); /* Optimistic Blue 70% */ + border-radius: 12px 0 12px 12px; + color: #091f2f; /* Charles Blue */ + padding: 16px 8px; + font-style: normal; + text-align: right; + margin: 0 0 0 0; + max-width: 85%; + float: right; + overflow-wrap: anywhere; +} +.bos-search-aisearchform .search-request-progress-wrapper { + padding-top: 32px; +} +.bos-search-aisearchform .search-request-progress:last-child { + display: none; +} + +.aienabledsearchform #search-conversation-wrapper .search-request-wrapper .search-request-progress { + background-image: url(../img/loading1_550.gif); + width: 100%; + background-size: 100% 75%; + background-repeat: no-repeat; + height: calc(80vw * 300 / 550); + opacity: 0.2; +} + +/** + * Long Answer + */ +.bos-search-aisearchform .search-response-wrapper { + margin: 24px 0; + max-width: 100%; + width: 100%; + padding: 0 0 0 0; + display: flex; + flex-flow: column; +} +.bos-search-aisearchform .search-response { + padding: 0; + color: #585858; + font-size: 1em; + margin-bottom: 24px; +} +.bos-search-aisearchform .search-response-title { + padding-left: 42px; + margin-bottom: 24px; + font-size: calc(1em + 2px); + font-family: Montserrat, Arial, sans-serif; + font-weight: 700; + color: #091F2F; + letter-spacing: 1px; + padding-top: 12px; +} +.bos-search-aisearchform .search-response-title:before { + content: ""; + background-image: url('../img/twinkle.svg'); + background-repeat: no-repeat; + position: absolute; + height: 32px; + width: 32px; + margin-left: -42px; + margin-top: -8px; +} +/** + * Citations + */ +.aienabledsearchform .search-citations-title { + display:none; + color: #091f2f; +} +.aienabledsearchform .search-citations-drawer .dr-c { + margin: 0; + padding: 16px 0; +} +.aienabledsearchform .search-citations-drawer .search-citation { + margin: 0 0 2px 0; + display: block; + font-size: 1em; + font-family: 'Lora', 'Georgia', serif; + line-height: calc(1em * 1.3); + text-decoration: underline; + -webkit-box-orient: vertical; + color: #58585b; + padding: 16px; +} +.aienabledsearchform .search-citations-drawer.show-more .search-citation:hover { + color: #1871bd; + background-color: #F2F2F2; + border-radius: 2px; +} +.aienabledsearchform .search-citations-drawer.show-more .search-citation:focus, +.aienabledsearchform .search-citations-drawer.show-more .search-citation:active { + color: #175182; + background-color: #f2f2f2; + border-radius: 2px; +} +/** + * List of links + */ +.bos-search-aisearchform .search-results-wrapper { + margin-top: 24px; + padding: 0 0 0 0; + margin-bottom: 24px; +} +.bos-search-aisearchform .search-results-wrapper .results-title { + font-size: calc(1em + 2px); + line-height: calc((1em + 2px) * 1.3); + margin-bottom: 24px; +} +.bos-search-aisearchform .search-result-wrapper { + padding: 16px 12px 16px 0; + max-width: 100%; + width: 100%; +} +.bos-search-aisearchform .search-result-wrapper:hover { + background-color: #F2F2F2; + margin-left: -12px; + padding-left: 12px; + width: calc(100% + 12px); + max-width: calc(100% + 12px); +} +.bos-search-aisearchform .search-result-wrapper:active { + background-color: rgba(40,139,228,0.10); +} +.bos-search-aisearchform .search-result-wrapper:visited {} +.bos-search-aisearchform .search-result-title { + font-family: 'Lora', 'Georgia', serif; + font-size: calc(1em + 2px); + line-height: calc((1em + 2px) * 1.2); + font-weight: 700; + margin-bottom: 12px; +} +.bos-search-aisearchform .search-result-title { + color: #1871bd; /* Charles Blue */ + height: 100%; + display: block; + margin-bottom: 5px; + text-decoration: none; +} +.bos-search-aisearchform .search-result-sub, +.bos-search-aisearchform .search-result { + font-size: 1em; + line-height: 1.75em; + color: #58585B; + margin-bottom: 12px; +} +.bos-search-aisearchform .search-result-sub { + font-size: calc(1em - 2px); +} +.bos-search-aisearchform .search-result-link-mobile { + display: none; + text-decoration: underline; + color: #58585b; +} +.bos-search-aisearchform .search-result-link { + display: block; + color: #58585B; + text-decoration: underline; + max-width: 100%; + overflow-wrap: anywhere; +} +.bos-search-aisearchform .ajax-progress { + margin-left: 10%; +} +/** + * GENERAL + */ +.bos-search-aisearchform .br--4 { + border-radius: 4px; +} +.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper { + padding: 12px; + margin: 0; + height: 100%; + display: flex; + align-items: center; +} +.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb { + background-color: rgba(40, 139, 228, 0.1); + border: 0 solid rgba(40,139,228,0.1); +} +.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb:focus-visible, +.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb:focus, +.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb:hover { + background-color: rgba(40, 139, 228, 0.2); + border: 0px solid rgba(40,139,228,0.2); +} +.bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper.bg--lb:active { + background-color: rgba(40, 139, 228, 0.4); + border: 0px solid rgba(40, 139, 228, 0.4); +} +.bos-search-aisearchform #welcome-cards #edit-cards .card-content-wrapper { + padding: 0; + margin: auto 0; + height: max-content; + height: auto; + width: 100%; +} +.bos-search-aisearchform #welcome-cards #edit-cards .card-content { + padding: 0; + margin: 0 auto; +} + +.bos-search-aisearchform #welcome-copy { + width: 100%; + margin-top: 5%; + margin-bottom: 35px; +} +.bos-search-aisearchform #welcome-title { + font-family: 'Montserrat', Arial, sans-serif; + letter-spacing: 0; + color: #091f2f; + font-weight: 600; + font-size: calc(1em + 14px); + line-height: calc(1em + 14px); +} +.bos-search-aisearchform #welcome-body { + font-size: 1em; + margin-top: 31px; + margin-bottom: 31px; +} +.bos-search-aisearchform .ajax-progress .message { + font-family: 'Montserrat', Arial, sans-serif; + letter-spacing: 1px; + color: #989898; + font-size: calc(1em + 2px); +} +.bos-search-aisearchform .ajax-progress-throbber .throbber { + background-size: 24px; + padding-right: 18px; +} + +/** + * DESKTOP adjustments + * This component considers mobile upto window width of 480px + */ + +@media screen and (min-width: 480px) { + .bos-search-aisearchform { + } + #drupal-modal { + padding: .5em 1em; + } + .aienabledsearchform #search-conversation-wrapper .search-request-wrapper div.search-request { + max-width: 60%; + margin: 0; + width: fit-content; + float: right; + position: relative; + /* z-index: 11; */ + } + .aienabledsearchform #search-conversation-wrapper .search-request-wrapper .search-request { + width: 100%; + margin: 0 0 0 10%; + } + .aienabledsearchform #search-conversation-wrapper .search-request-wrapper .search-request-progress { + background-image: url(../img/loading1_550.gif); + width: 50%; + background-size: 97% 75%; + background-repeat: no-repeat; + height: calc(.67 * 300px); + clear: both; + opacity: .2; + } + .bos-search-aisearchform .search-response { + flex-basis: 60%; + padding-right: 40px; + flex-grow: 0; + } + .bos-search-aisearchform .search-response:before { + left: revert; + margin-top: -48px; + padding-left: calc(32px + 16px); + } + .bos-search-aisearchform #edit-welcome { + } + .bos-search-aisearchform #welcome-copy { + max-width: 80%; + margin: 0 auto; + } + .bos-search-aisearchform #welcome-title { + margin: 0 0 48px 0; + padding: 0; + font-size: calc(1em + 30px); + line-height: normal; + } + .bos-search-aisearchform #welcome-body { + font-size: calc(1em + 2px); + margin: 0; + padding: 0; + } + .bos-search-aisearchform #welcome-cards { + margin: 24px 0 0 0; + padding: 0; + } + .bos-search-aisearchform .search-results-wrapper { + max-width: 100%; + width: 100%; + } + .bos-search-aisearchform .search-result-wrapper { + padding: 12px 12px 12px 0; + margin-bottom: 22px; + margin-top: 22px; + } + .bos-search-aisearchform .search-result-title a { + margin-bottom: 0; + } + .bos-search-aisearchform .search-result-sub, + .bos-search-aisearchform .search-result { + padding-left: 0; + margin-top: 12px; + } + .bos-search-aisearchform .search-result-link-mobile { + display: none; + } + .bos-search-aisearchform .search-result-link { + display: block; + } + .bos-search-aisearchform .ajax-progress { + margin-left: 10%; + } + .bos-search-aisearchform #welcome-cards #edit-cards .goc-grid { + column-gap: 30px; + align-items: stretch; + flex-wrap: nowrap; + } + .bos-search-aisearchform .bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper { + padding: 12px 16px; + margin: 0 0 0 0; + } + + .bos-search-aisearchform #welcome-cards #edit-cards .card-content-wrapper { + margin: 0; + padding: 0 4px; + display: block; + } + + .bos-search-aisearchform #welcome-cards #edit-cards .card-content { + padding: 26px 0; + text-align: left; + } + .bos-search-aisearchform #welcome-cards #edit-cards .goc-wrapper { + margin-bottom: 132px; + } + .bos-search-aisearchform #welcome-cards #edit-cards .goc-title { + font-size: calc(1em); + } + .bos-search-aisearchform #welcome-cards #edit-cards .grid-of-cards, .grid-of-cards.b--fw { + margin:0; + } + .bos-search-aisearchform #welcome-cards #edit-cards { + width: 100%; + margin-top: 13px; + } + .bos-search-aisearchform #welcome-cards #edit-cards .card-wrapper { + height: auto; + display: flex; + justify-content: center; + align-items: center; + } + .bos-search-aisearchform .search-request-wrapper { + width: 100%; + } + .bos-search-aisearchform .search-request-progress-wrapper { + display: flex; + flex-flow: row; + align-items: stretch; + padding-top: 48px; + } + .bos-search-aisearchform .search-request-progress:last-child { + display: block; + } + .aienabledsearchform .search-response-wrapper { + flex-flow: row; + flex-wrap: wrap; + padding: 0; + font-size: calc(1em + 2px); + line-height: calc((1em + 2px)* 1.5); + margin-top: 36px; + } + .aienabledsearchform .search-citations-wrapper { + flex-basis: 40%; + flex-grow: 0; + background-color: #f9f9f9; + } + .aienabledsearchform .search-citations-wrapper.hide-citation { + background-color: transparent; + } + .aienabledsearchform .search-citations-wrapper:after { + clear: both; + content: ""; + display: table; + } + .aienabledsearchform .search-citations-title { + display:block; + font-size: calc(1em + 2px); + font-weight: 700; + font-family: 'Montserrat', Arial, sans-serif; + letter-spacing: 1px; + padding: 16px; + border-bottom: 2px solid #d2d2d2; + } + .aienabledsearchform .search-citations-drawer .dr-c, + .aienabledsearchform .search-citations-drawer { + background: initial; + } + .aienabledsearchform .search-citations-drawer label { + display:none; + } + .aienabledsearchform .search-citations-drawer .dr-c { + display:block; + padding: 16px 0 16px 0; + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/card.css b/docroot/modules/custom/bos_components/modules/bos_search/css/card.css new file mode 100644 index 0000000000..35e952a1e7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/card.css @@ -0,0 +1,26 @@ +.goc-grid .card {} +.goc-grid .card-wrapper { + padding: 12px 16px; + border-radius: 4px; + border: none; +} +.goc-grid .card-wrapper { + border: none; + font-family: 'Lora', 'Georgia', serif; + font-size: 1rem; + height: fit-content; +} +.goc-grid .card-title {} +.goc-grid .card-subtitle {} +.goc-grid .card-wrapper div.card-content-wrapper { + padding: 0; + height: 120px; + text-align: center; + border: none; +} +.goc-grid .card-wrapper .card-content-wrapper div.card-content { + color: #091f2f; /* Charles Blue */ + margin: auto 20px; + line-height: 1.2rem; +} +.goc-grid .card-image {} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/disclaimer.css b/docroot/modules/custom/bos_components/modules/bos_search/css/disclaimer.css new file mode 100644 index 0000000000..a23304c818 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/disclaimer.css @@ -0,0 +1,145 @@ +.aienableddisclaimerform { + font-family: 'Lora', 'Georgia', serif; + font-size: 16px; + line-height: 26px; + letter-spacing: 0.5px; + color: #58585b; + padding: 68px 32px 42px 32px; + border-radius: 2px; + width: 326px !important; + border: none !important; +} +.aienableddisclaimerform .ui-titlebar-hidden.ui-dialog-titlebar { + display: none; + position: absolute; + top: calc((40px - 13px) / 2 ); + right: calc((40px - 13px) / 2 ); + background-color: transparent; + border: none; + height: 40px; + width: 326px; +} +.aienableddisclaimerform .ui-titlebar-hidden .ui-dialog-title { + display: none; +} +.aienableddisclaimerform .ui-titlebar-hidden .ui-dialog-titlebar-close { + background-color: transparent; + border: none; + width: 26px; + height: 26px; + top: 0; + margin: 0; + padding: 0; + right: 0; +} +.aienableddisclaimerform .ui-titlebar-hidden .ui-icon-closethick { + background-image: url("../img/x.svg") ; + background-size: 13px; + background-position: unset; +} +.aienableddisclaimerform #drupal-modal.ui-dialog-content { + padding: 0; + margin: 0; +} +.aienableddisclaimerform .search-disclaimer-wrapper { + width: 263px; +} +.aienableddisclaimerform .search-disclaimer-wrapper .h2 { + font-family: 'Montserrat', Arial, sans-serif; + font-size: calc(1em + 14px); + letter-spacing: 1px; + line-height: 25px; + font-weight: 700; + color: #091F2F; +} +.aienableddisclaimerform .search-disclaimer-wrapper .search-disclaimer-text { + font-size: 1em; + line-height: 25px; + margin-top: 16px; + margin-bottom: 40px; + padding: 0; +} +.aienableddisclaimerform .button { + font-family: 'Montserrat', Arial, sans-serif; + letter-spacing: 1px; + width: 100%; + height: 50px; + padding: 10px; + border: 0 transparent solid; + border-radius: 4px; +} +.aienableddisclaimerform .form-submit, +.aienableddisclaimerform .form-submit:visited { + background-color: #288BE4; + color: #FFFFFF; + outline: none; +} +.aienableddisclaimerform .form-submit:focus, +.aienableddisclaimerform .form-submit:hover { + background-color: #1376CF; + color: #FFFFFF; +} +.aienableddisclaimerform .form-submit:active { + background-color: #0062BB; + color: #FFFFFF; +} +.aienableddisclaimerform .btn-cancel, +.aienableddisclaimerform .btn-cancel:visited { + margin-top: 24px; + background-color: transparent; + color: #091f2f; +} +.aienableddisclaimerform .btn-cancel:focus, +.aienableddisclaimerform .btn-cancel:hover { + background-color: inherit; + color: #1376CF; +} +.aienableddisclaimerform .btn-cancel:active { + background-color: inherit; + color: #1376CF; + border: 2px #1376cf solid; + border-radius: 2px; +} + +/** + * DESKTOP adjustments + * This component considers mobile upto window width of 480px + */ + +@media screen and (min-width: 480px) { + .aienableddisclaimerform { + width: 591px !important; + padding: 36px 66px; + } + .aienableddisclaimerform .ui-titlebar-hidden.ui-dialog-titlebar { + top: 32px; + right: 42px; + padding: 0; + width: 483px; + } + .aienableddisclaimerform .ui-titlebar-hidden .ui-icon-closethick { + top: 3px; + left: 3px; + margin:0; + padding: 0; + background-size: 20px; + height: 20px; + background-position: center; + width: 20px; + position: absolute; + } + .aienableddisclaimerform .search-disclaimer-wrapper { + margin-top: 40px; + width: 449px; + } + .aienableddisclaimerform .search-disclaimer-wrapper .h2 { + font-size: calc(1em + 16px); + line-height: 25px; + } + .aienableddisclaimerform .search-disclaimer-wrapper .search-disclaimer-text { + font-size: calc(1em + 2px); + line-height: 25px; + padding: 0; + margin-top: 32px; + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/grid_of_cards.css b/docroot/modules/custom/bos_components/modules/bos_search/css/grid_of_cards.css new file mode 100644 index 0000000000..ac8f045517 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/grid_of_cards.css @@ -0,0 +1,21 @@ +.grid-of-cards { + padding-top: 0; + margin-top: 48px; + font-family: 'Lora', 'Georgia', serif; + font-size: 1rem; +} +.goc-wrapper { + padding: 0; + width: 80%; + max-width: 80%; + margin-bottom: 72px; +} +.goc-title { + margin: 0; + padding: 0; +} +.goc-grid { + margin-top: 24px; + row-gap: 16px; + width: 100%; +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/modal_close.css b/docroot/modules/custom/bos_components/modules/bos_search/css/modal_close.css new file mode 100644 index 0000000000..d7157985b5 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/modal_close.css @@ -0,0 +1,97 @@ +.modal-close-wrapper { + display:none; + flex-direction: row; + font-family: 'Lora', 'Georgia', serif; + font-size: 16px; + letter-spacing: 0.5px; + position: sticky; + min-height: 72px; + background-color: #ffffff; + z-index: 10; + color: #58585b; +} +.aienabledsearchform.has-results .modal-close-wrapper { + display:flex; + background-color: #ffffff; +} + +.modal-close-wrapper .ai-form-reset { + display: flex; + align-items: center; + position: absolute; + top: 10px; + margin-top: 25px; +} +.modal-close-wrapper .ai-reset-button { + padding: 8px 22px 8px 16px; + display: block; + max-height: 44px; + line-height: calc(1em + 10px); + border-radius: 4px; + font-size: calc(1em - 2px); +} +.modal-close-wrapper .ai-form-reset{ + background: rgba(40, 139, 228, 0.10); +} +.modal-close-wrapper .ai-form-reset:hover{ + background: rgba(40, 139, 228, 0.20); +} +.modal-close-wrapper .ai-form-reset:active{ + background: rgba(40, 139, 228, 0.40); +} + +.modal-close-wrapper .ai-reset-button:after { + content: "Reset Search"; + margin: 0 0 0 28px; + width: max-content; + display: block; +} +.modal-close-wrapper .ai-reset-button:before { + content: ""; + background-image: url("../img/plus.svg"); + background-position: center; + background-repeat: no-repeat; + background-position-x: left; + border: none; + position: absolute; + left: 0; + top: 10px; + height: 20px; + width: 20px; + margin-left: 16px; + margin-right: 8px; + background-size: 20px; + display: block; +} +.modal-close-wrapper .modal-close { + background-image: url("../img/modal_close.svg"); + background-position: center; + background-repeat: no-repeat; + border: none; + right: 24px; + position: absolute; + height: 18px; + width: 18px; + top: 37px; +} +.modal-close-wrapper .ai-form-reset:hover, +.modal-close-wrapper .modal-close:hover { + cursor: pointer; +} +/** + * DESKTOP adjustments + * This component considers mobile upto window width of 480px + */ + +@media screen and (min-width: 480px) { + + .modal-close-wrapper {} + .ai-form-reset { } + .ai-form-reset .ai-reset-button { } + + .ai-form-reset .ai-reset-button .ai-reset-icon:before { + scale: 100%; + top: 26px; + margin-left: 10px; + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/quote_card.css b/docroot/modules/custom/bos_components/modules/bos_search/css/quote_card.css new file mode 100644 index 0000000000..d286fdc544 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/quote_card.css @@ -0,0 +1,8 @@ +.goq-quote-text {} +.goq-quote-details {} +.goq-quote-photo {} +.goq-quote-photo img {} +.goq-quote-photo a {} +.goq-quote-person-details {} +.goq-quote-name {} +.goq-quote-location {} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/css/search_bar.css b/docroot/modules/custom/bos_components/modules/bos_search/css/search_bar.css new file mode 100644 index 0000000000..1343baba7f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/css/search_bar.css @@ -0,0 +1,117 @@ + +div.search-bar-wrapper { + font-family: 'Lora', 'Georgia', serif; + color: #091F2F; /* Charles Blue */ + width: 100%; + margin: 0 auto 0 auto; + font-size: 16px; + letter-spacing: 0.5px; +} +div.search-bar-title { + margin: 0 auto; + width: 80%; +} +div.search-bar-input-wrapper { + position: relative; +} +div.search-bar-input { + background-color: rgba(40, 139, 228, 0.1); + border-radius: 8px; + border: 1px solid rgba(40, 139, 228, 1); + padding: 2px; + font-size: calc(1em - 2px); + margin: 0; +} +div.search-bar-input:focus, +div.search-bar-input:focus-within, +div.search-bar-input:hover, +div.search-bar-input:active { + border: 3px solid rgba(40, 139, 228, 1); + padding: 0; +} + +div.search-bar-submit { + background-position: center; + background-color: rgba(24, 113, 189, 0.8); + background-repeat: no-repeat; + border: 1px solid rgba(24, 113, 189, 0.5); /* Optimistic Blue 50% */ + border-radius: 8px; + position: absolute; + right: 12px; + top: 12px; + height: 44px; + width: 44px; + font-size: inherit; +} +div.search-bar-submit:hover, +div.search-bar-submit:hover:not(:disabled), +div.search-bar-submit:active { + background-color: rgba(24, 113, 189, 1); +} +div.search-bar-microphone { + background-image: url("../img/microphone.svg"); + background-position: center; + background-color: transparent; + background-repeat: no-repeat; + border: none; + border-radius: 8px; + position: absolute; + right: 72px; +} +div.search-bar-microphone:hover, +div.search-bar-microphone:hover:not(:disabled), +div.search-bar-microphone:active{ + background-color: rgba(24, 113, 189, 0.5); +} +input.search-bar { + color: #091f2f; /* Charles Blue */ + line-height: calc(48px * 1.2); + background-color: transparent; + border:0; + height: 44px; + margin: 8px 0 8px 0; + padding: 0 12px 0 24px; + font-size: calc(1em + 2px); + width: calc(100% - 70px); + outline: none; +} +.search-bar:focus-visible { +} +input.search-bar.searching, +.search-bar::placeholder { + color: rgba(9, 31, 47, 0.5); /* Charles Blue 50% */ + font-style: normal; + font-size: 1em; +} +div.search-bar-description { + text-align: center; + color: #555; + margin: 3px auto 0 auto; + font-size: calc(1em - 4px); + line-height: 14px; +} +/** + * DESKTOP adjustments + * This component considers mobile upto window width of 480px + */ + +@media screen and (min-width: 480px) { + input.search-bar { + height: 48px; + } + div.search-bar-submit { + height: 48px; + width: 48px; + } + div.search-bar-input-wrapper { + width: 100%; + margin: 5px auto 0 auto; + } + div.search-bar-description { + font-size: calc(1em + -2px); + padding: 8px 0 8px 0; + } + div.search-bar-input { + font-size: calc(1em + 2px); + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_1100.gif b/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_1100.gif new file mode 100644 index 0000000000..94811649c4 Binary files /dev/null and b/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_1100.gif differ diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_550.gif b/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_550.gif new file mode 100644 index 0000000000..1926509d72 Binary files /dev/null and b/docroot/modules/custom/bos_components/modules/bos_search/img/loading1_550.gif differ diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_1000.gif b/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_1000.gif new file mode 100644 index 0000000000..da42f40a7f Binary files /dev/null and b/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_1000.gif differ diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_550.gif b/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_550.gif new file mode 100644 index 0000000000..64c8069555 Binary files /dev/null and b/docroot/modules/custom/bos_components/modules/bos_search/img/loading2_550.gif differ diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/microphone.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/microphone.svg new file mode 100644 index 0000000000..9d8da0e83c --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/microphone.svg @@ -0,0 +1,3 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/modal_close.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/modal_close.svg new file mode 100644 index 0000000000..6cb3723a31 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/modal_close.svg @@ -0,0 +1,3 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/plus.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/plus.svg new file mode 100644 index 0000000000..5ef951763c --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/search.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/search.svg new file mode 100644 index 0000000000..a6343b0ef9 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/speaker.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/speaker.svg new file mode 100644 index 0000000000..554a8aeab7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/speaker.svg @@ -0,0 +1,3 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/thumbdown.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/thumbdown.svg new file mode 100644 index 0000000000..1cdc4db574 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/thumbdown.svg @@ -0,0 +1,4 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/thumbup.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/thumbup.svg new file mode 100644 index 0000000000..1f881305d7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/thumbup.svg @@ -0,0 +1,3 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle.svg new file mode 100644 index 0000000000..71bdf1a22f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle.svg @@ -0,0 +1,3 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle_white.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle_white.svg new file mode 100644 index 0000000000..7909b174a8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/twinkle_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/img/x.svg b/docroot/modules/custom/bos_components/modules/bos_search/img/x.svg new file mode 100644 index 0000000000..16a4157cc8 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/img/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/includes/aisearch_form_theme.inc b/docroot/modules/custom/bos_components/modules/bos_search/includes/aisearch_form_theme.inc new file mode 100644 index 0000000000..6357ac88c6 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/includes/aisearch_form_theme.inc @@ -0,0 +1,222 @@ + $name) { + + $templates = AiSearch::getFormTemplates($theme); + foreach($templates as $template => $template_name) { + $idx_file = str_replace(["_", " "], "-", $template); + $idx = str_replace(["-", " "], "_", $template); + if ($idx == "results") { + $existing["{$idx}__{$theme}"] = [ + 'template' => "presets/$theme/$idx_file", + 'variables' => [ + "response" => NULL, + "items" => NULL, + "metadata" => NULL, + "references" => NULL, + "citations" => NULL, + "content" => NULL, + "id" => NULL, + "feedback" => NULL, + ], + ]; + } + else { + $existing["{$idx}__{$theme}"] = [ + 'template' => "presets/$theme/$idx_file", + 'base_hook' => $idx, + 'render element' => 'children', + ]; + } + } + + } + +} + +function _bos_search_snippet_theme(array &$existing):void { + $existing['modal_close'] = [ + 'template' => 'snippets/modal-close', + 'variables' => [ + ], + ]; + $existing["aisearch_feedback"] = [ + 'template' => 'snippets/aisearch-feedback', + 'variables' => [ + 'thumbsup' => FALSE, + 'thumbsdown' => FALSE, + ], + ]; +} + +/** + * Implements hook_preprocess(). + */ +function bos_search_preprocess(&$variables, $hook, $info) { + + if (!AiSearch::isBosSearchThemed()) { + return; + } + + switch ($hook) { + + case "block": + template_preprocess_block($variables); + if ($variables["elements"]["#plugin_id"] == "Ai-enabled-search-button") { + $template = explode("/" ,$info["template"]); + $theme = $template[1]; + $variables["content"]['#preset_theme'] = $theme; + } + break; + + case "form": + if ($variables["element"]["#form_id"] == "bos_search_AISearchForm") { + template_preprocess_form($variables); + + // Add some extra configuration information + $config = \Drupal::request()->query->all(); + if (empty($config)){ + $config["preset"] = $variables["element"]["AiSearchForm"]["content"]["preset"]["#value"]; + $config["display"] = "block"; + } + if ($variables["configuration"] = $config) { + $variables["preset"] = AiSearch::getPresetValues($variables["configuration"]["preset"] ?? 'default') ?? []; + + // If required, add in the modal close header . + if (($variables["configuration"]["display"] ?? "block") == "modal" + || $variables["preset"]["searchform"]["searchbar"]["allow_reset"]) { + $variables["form_header"] = [ + '#type' => 'modal_close', + '#theme' => 'modal_close', + ]; + } + + // Include any custom styles and scripts. + $custom_theme_path = "/modules/custom/bos_components/modules/bos_search/templates/presets/{$variables['preset']['searchform']['theme']}"; + $variables["#attached"]["drupalSettings"]["bos_search"] = [ + 'dynamic_script' => "$custom_theme_path/js/preset.js", + 'dynamic_style' => "$custom_theme_path/css/preset.css", + 'waiting_text' => $variables["preset"]["searchform"]['searchbar']["waiting_text"], + ]; + // Include script to load custom scripts and styles. + $variables['#attached']['library'][] = 'bos_search/dynamic-loader'; + } + } + break; + + case "fieldset": + template_preprocess_fieldset($variables); + break; + + + case "container": + template_preprocess_container($variables); + break; + + case "input": + _bos_search_preprocess_input($variables, $hook); + break; + + case "textarea": + template_preprocess_textarea($variables); + $variables["attributes"]["class"][] = "txt-f"; + break; + + case "form_element": + template_preprocess_form_element($variables); + break; + + case "form_element_label": + template_preprocess_form_element_label($variables); + break; + + default: + break; + } +} + +function _bos_search_preprocess_input(&$variables, $hook):void { + + template_preprocess_input($variables); + $variables["attributes"] += $variables["element"]["#attributes"]; + $variables["children"] = $variables['element']; + +} + +/** + * Implements hook_theme_suggestions_alter(). + */ +function _search_form_suggestions(array &$suggestions, array &$variables, $hook):void { + + if (!empty($variables["element"])) { + // Get the form theme being used by the active preset, or else use 'default'. + $node = \Drupal::request()->attributes->get('node', NULL); + $preset = AiSearch::getPreset(node: $node); + $form_theme = AiSearch::getPresetValues($preset)["searchform"]["theme"]; + + switch ($hook) { + case "form": + if (isset($variables["element"]["#errors"])) { + return; + } + if ($variables["element"]["#form_id"] == 'bos_search_AISearchForm') { + $suggestions[] = "form__$form_theme"; + } + break; + case "form_element": + case "form_element_label": + if (in_array("AiSearchForm", $variables["element"]["#array_parents"] ?? [])) { + $suggestions[] = "{$hook}__$form_theme"; + } + break; + default: + if (in_array("AiSearchForm", $variables["element"]["#array_parents"] ?? [])) { + if (in_array($variables["element"]["#type"], [ + "hidden", + "button", + "submit", + ])) { + $suggestions[] = "input__$form_theme"; + } + else { + $suggestions[] = $variables["element"]["#type"] . "__$form_theme"; + } + } + break; + } + } + + // Adds suggestions to allow themeing the AI Search Blocks. + // The block will use a template based on the preset being used. + if ($hook == "block") { + + if ($variables["elements"]["#plugin_id"] == "Ai-enabled-search-button") { + // Get the theme from the preset - or use 'default' + if ($preset = $variables["elements"]["#configuration"]["aisearch_config_preset"] ?? FALSE) { + $form_theme = AiSearch::getPresetValues($preset)["searchform"]["theme"] ?? $form_theme; + } + $suggestions[] = "block__button__{$form_theme}"; + } + elseif ($variables["elements"]["#plugin_id"] == "Ai-enabled-search-form") { + $preset = AiSearch::getPreset(); + $form_theme = AiSearch::getPresetValues($preset)["searchform"]["theme"]; + $suggestions[] = "block__form__{$form_theme}"; + } + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/includes/bos_theme_theme.inc b/docroot/modules/custom/bos_components/modules/bos_search/includes/bos_theme_theme.inc new file mode 100644 index 0000000000..535229a61f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/includes/bos_theme_theme.inc @@ -0,0 +1,104 @@ + 'components/quote-card', + 'variables' => [ + "content" => "", + "person" => "", + "show_quotes" => "", + "picture" => "", + "location" => "", + "attributes" => [], + ], + ]; + $existing['card'] = [ + 'template' => 'components/card', + 'variables' => [ + "attributes" => [], + "link" => "", + "image" => "", + "title" => "", + "title_attributes" => [], + "subtitle" => "", + "subtitle_attributes" => [], + "content" => "", + "content_attributes" => [], + "parent" => "", + "parent_array" => [], + ], + ]; + $existing['grid_of_cards'] = [ + 'template' => 'components/grid-of-cards', + 'variables' => [ + "attributes" => [], + "title" => "", + "title_attributes" => [], + "cards" => [], + "type_array" => [], + "type" => '', + ], + "render element" => "cards", + ]; + $existing['search_bar'] = [ + 'template' => 'components/search-bar', + 'variables' => [ + "wrapper_attributes" => [], + "attributes" => [], + "title" => "", + "title_attributes" => [], + "icon" => "/" . \Drupal::moduleHandler()->getModule("bos_search")->getPath() . "/img/search.svg", + "value" => "", + "default_value" => "", + "description" => '', + "description_display" => "after", + "audio_search_input" => FALSE + ], + ]; +} + +/** + * Implements hook_preprocess_HOOK(). + */ +function bos_search_preprocess_grid_of_cards(&$variables):void { + + // Give the grid of cardas a unique ID, this is useful for anchoring. + $variables["attributes"]["id"] = $variables["attributes"]["data-drupal-selector"] ?? "grid_" . rand(100000,999999); + + // Give each card a unique ID within this grid. + foreach($variables['cards'] as $key => &$card) { + $variables["type_array"][] = $card["#type"]; + $card["#attributes"]["id"] = "{$variables["attributes"]["id"]}_card_{$key}"; + $card["#attributes"]["class"][] = "card_{$key}"; + $card["#parent"] = $variables["attributes"]["id"]; + $card["#parent_array"][] = $variables["attributes"]["id"]; + } + +} + +function _bos_search_preprocess_search_bar(&$variables):void { + $variables['value'] = $variables['value'] ?: $variables['default_value']; + $variables['wrapper_attributes'] = new \Drupal\Core\Template\Attribute(); + $variables["attributes"]["type"] = "text"; + $variables["attributes"]["id"] = $variables["attributes"]["aria-describedby"] ?? "edit-search-bar"; + $variables["attributes"]["name"] = str_replace("edit-", "", $variables["attributes"]["data-drupal-selector"] ?? "search-bar"); + $variables["title_attributes"]["for"] = $variables["attributes"]["id"]; +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/ai_feedback.js b/docroot/modules/custom/bos_components/modules/bos_search/js/ai_feedback.js new file mode 100644 index 0000000000..a11c7df6dd --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/js/ai_feedback.js @@ -0,0 +1,73 @@ +(function ($, Drupal, once) { + Drupal.behaviors.ai_search_feedback = { + attach: function (context, settings) { + once('feedbackForm', '.ai-feedback-wrapper', context).forEach( + function (element) { + $(document).on("ajaxComplete", function (event, xhr, settings) { + if (xhr.statusText.toString() === 'success') { + var thisdialog = $('.feedback-dialog'); + if (settings.url.toString().startsWith("/form/ai-search-feedback") && thisdialog.length > 0) { + if (thisdialog.find(".text-count-message").length > 0) { + var more = thisdialog.find('textarea[name=tell_us_more]'); + more.on("keyup", function(element){textarea_counter(element.target, thisdialog);}) + var submission = $(".aienabledsearchform").find("#search-conversation-wrapper"); + var question = submission.find(".search-request").last().html(); + var summary = submission.find(".search-response-text").last().html(); + if (question) { + thisdialog.find('.search-question').val(question); + } + if (summary) { + thisdialog.find('.search-summary').val(summary); + } + } + else { + var message = thisdialog.text().trim("\n"); + message = message.replace("Close","").trim(' '); + $(".aienabledsearchform .ai-feedback-confirm").last().text(message).show(); + $(".aienabledsearchform .ai-feedback-buttons").last().hide(); + var searchform = $('.aienabledsearchform'); + var thumbs = $('.ai-feedback-wrapper').last(); + move_div_to_middle(searchform, thumbs); + $("#drupal-modal").dialog("close"); + } + } + } + }); + } + ); + } + }; + + var textarea_counter = function (element, thisdialog) { + var textbox = $(element).val(); + var count = parseInt(textbox.length); + if (!count) { + thisdialog.find('.text-count-message').text('200 characters allowed'); + } + else { + thisdialog.find('.text-count-message').text((200 - count) + ' characters remaining'); + } + }; + + var move_div_to_middle = function(searchform, div) { + if ($(".search-response-wrapper").length) { + var offsetHeight = ((div.offset().top) - (searchform.offset().top) - ($(window).height() / 3)); + var scroll_layer = $("html, body"); + if (searchform.hasClass("aisearch-modal-form")) { + scroll_layer = searchform; + offsetHeight = ((div.offset().top) - (searchform.offset().top) - window.height() ); + } + scroll_layer.animate({ + scrollTop: offsetHeight, + }, 'fast'); + } + } + + var add_click_to_feedback = function (element) { + $(element).on("click", function (event) { + even.peventDefault(); + var form = $(".ai-feedback-wrapper"); + }); + } + +})(jQuery, Drupal, once); diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/ai_searchbutton.js b/docroot/modules/custom/bos_components/modules/bos_search/js/ai_searchbutton.js new file mode 100644 index 0000000000..1a12f2eaf1 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/js/ai_searchbutton.js @@ -0,0 +1,6 @@ +(function ($, Drupal, once) { + Drupal.behaviors.ai_search_button = { + attach: function (context, settings) { + } + } +})(jQuery, Drupal, once); diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/bos_search.js b/docroot/modules/custom/bos_components/modules/bos_search/js/bos_search.js new file mode 100644 index 0000000000..286e09db6f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/js/bos_search.js @@ -0,0 +1,226 @@ +(function ($, Drupal, once) { + Drupal.behaviors.aiSearch = { + attach: function (context, settings) { + var new_height = 0; + once('loadExample', '.bos-search-aisearchform .card', context).forEach( + function(element){ + if (!new_height) { + new_height = find_max_height($(element.parentElement).children()); + } + $(element).css({"min-height": new_height}); + $(element).on("click", function(event) { + $(".bos-search-aisearchform .search-bar").val($(element).find(".card-content").text()); + submit_form(); + }); + } + ); + once('aiSearch', '.bos-search-aisearchform #search-bar-submit', context).forEach( + function (element) { + $(element).click(function (event) { + event.preventDefault(); + submit_form(); + }); + } + ); + once('aiSearch2', '.bos-search-aisearchform .search-bar', context).forEach( + function (element) { + $(element).keyup(function (event) { + if(event.originalEvent.key === "Enter") { + $('.bos-search-aisearchform #search-bar-submit').click(); + } + }); + } + ); + once('ajaxMonitor', '.aienabledsearchform', context).forEach( + function(element){ + $(document).on("ajaxError", function(event, xhr, settings, thrownError) { + event.preventDefault(); + var searchform = $('.aienabledsearchform'); + searchform.find(".search-bar") + .removeClass("searching") + .removeAttr('disabled') + .val('') + .focus(); + + if (drupalSettings.user.uid !== 0) { + console.log('Custom AJAX Error Handler: An error occurred.'); + console.log('Error details:', xhr.responseText); + } + }); + $(document).on("ajaxComplete", function(event, xhr, settings) { + var searchform = $('.aienabledsearchform'); + var this_request = searchform.find(".search-request").last(); + var this_response = searchform.find(".search-response-text").last(); + var this_citations = searchform.find(".search-citations-wrapper").last(); + + if (xhr.statusText.toString() === 'success') { + toggle_welcome_block(xhr.responseJSON); + limit_citations_height(this_response, this_citations); + toggle_citations_show_more(this_response, this_citations); + } + searchform.find('.search-request-progress-wrapper').remove(); + move_div_to_top(searchform, this_request); + searchform.find(".search-bar") + .removeAttr('disabled') + .removeClass("searching") + .val("") + .focus(); + }); + } + ); + + }, + + }; + + var submit_form = function () { + + var searchform = $('.aienabledsearchform'); + + if (validate_form(searchform)) { + + var welcome_block = searchform.find('#edit-welcome'); + // var search_bar=searchform.find("#search-bar-submit"); + + add_request_bubble(searchform); + var this_request = searchform.find(".search-request-wrapper").last(); + move_div_to_top(searchform, this_request); + + if (welcome_block.length > 0) { + collapse_welcome_block(searchform); + } + + searchform.find("input.form-submit").mousedown(); + + searchform.find('.search-bar') + .attr("disabled",'') + .addClass('searching') + .val(drupalSettings.bos_search.waiting_text) + .focus(); + + } + else { + searchform.find('.search-bar-input input').focus(); + } + + } + + var validate_form = function (searchform) { + if (searchform.find('.search-bar').val()) { + return true; + } + return false; + } + + var add_request_bubble = function(searchform) { + var request_text = searchform.find('.search-bar').val(); + searchform.find('#search-conversation-wrapper').append("" + + "
" + + "
" + request_text + "
" + + "
" + + "
" + + "
" + + "
" + + "
" + + "
" + + "
"); + } + + var collapse_welcome_block = function(searchform) { + searchform.find('#edit-welcome').slideUp('slow', function() { + searchform.animate({ + scrollTop: searchform.prop('scrollHeight') + }, 'fast'); + }); + } + + var toggle_welcome_block = function (responses) { + + var mainAction; + + $(responses).each(function (index, element) { + if (element.command === 'insert' && typeof mainAction === 'undefined') { + mainAction = element; + } + }); + + if (mainAction && mainAction.command === 'insert') { + // Looks like the ajax command succeeded. + if (mainAction.dialogOptions) { + // This is a modal form. + var classes = mainAction.dialogOptions.classes["ui-dialog"]; + if (classes && (!classes.match("aienableddisclaimerform") && !classes.match("feedback-dialog"))) { + // this is the main search form. + $('.bos-search-aisearchform').addClass('no-welcome'); + } + } + else if (mainAction.selector !== '#drupal-modal') { + // Not a modal form, and not using the modal selector therefore we are appending results. + $('.bos-search-aisearchform').addClass('no-welcome'); + } + } + + } + + var limit_citations_height = function(response, citations) { + var drawer = citations.find(".search-citations-drawer"); + while (response && drawer && response.height() < (drawer.height() - 40)) { + var elem = drawer.find('.search-citation:not(".hidden"):not(".search-citation-more")').last() + if (elem.length === 0){ + return; + } + elem.addClass("hidden").css({"display":"none"}); + drawer.addClass("show-more"); + } + } + + var toggle_citations_show_more = function(response, citations) { + var drawer = citations.find(".search-citations-drawer"); + if (citations.length && drawer.hasClass("show-more")) { + drawer + .find('.search-citation-more') + .on("click", function(e){ + e.preventDefault(); + drawer.removeClass("show-more") + .css({"overflow-y": "scroll"}) + .find(".search-citation.hidden") + .removeClass("hidden") + .css({"display": "block"}); + }); + drawer.css({"max-height": response.height() + "px"}); + drawer.find("dr-c").css({"min-height": response.height() + "px"}); + } + } + + var move_div_to_top = function(searchform, div) { + if ($(".search-response-wrapper").length) { + + var site_banner = $('.site-banner'); + site_banner = site_banner.length ? site_banner.height() : 0; + + var offsetHeight = ((div.offset().top) - (searchform.offset().top) - site_banner); + var scroll_layer = $("html, body"); + + if (searchform.hasClass("aisearch-modal-form")) { + scroll_layer = searchform; + offsetHeight = ((div.offset().top) - (searchform.offset().top) - site_banner); + } + + scroll_layer.animate({ + scrollTop: offsetHeight, + }, 'fast'); + + } + } + + var find_max_height = function(elements) { + var max_example_height = 0; + elements.each(function(idx, element) { + if (max_example_height < $(element).outerHeight()) { + max_example_height = $(element).outerHeight(); + } + }); + return max_example_height + "px"; + } + +})(jQuery, Drupal, once); diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/disclaimer.js b/docroot/modules/custom/bos_components/modules/bos_search/js/disclaimer.js new file mode 100644 index 0000000000..a61a82f993 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/js/disclaimer.js @@ -0,0 +1,40 @@ +(function ($, Drupal, once) { + Drupal.behaviors.searchDisclaimer = { + attach: function (context, settings) { + $(document).ready(function(){ + var disclaimerform = $('.aisearch-disclaimer-form .ui-button'); + if (disclaimerform.length && !disclaimerform.attr('disclaimer-once')) { + disclaimerform.click(function (event) { + event.preventDefault(); + Drupal.dialog("#drupal-modal").close(); + }); + disclaimerform.attr({ 'disclaimer-once': true }) + } + }); + + const element = $('.aienabledsearchform', context); + + // Only display the disclaimer once per form display + if (element.length > 0 && !element.attr('data-once-searchDisclaimer')) { + element.attr('data-once-searchDisclaimer', true); + + // Callback to create the disclaimer. + if (settings.disclaimerForm.triggerDisclaimerModal) { + Drupal.ajax({ + url: settings.disclaimerForm.openModal, + }).execute(); + + $(document).on("ajaxComplete", function(event, xhr, settings) { + // Fires when the disclaimer form is returned by ajax + $('.aienableddisclaimerform .btn-submit').click(function (event) { + event.preventDefault(); + Drupal.dialog("#drupal-modal").close(); + }); + }); + + } + + } + } + }; +})(jQuery, Drupal, once); diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/dynamic_loader.js b/docroot/modules/custom/bos_components/modules/bos_search/js/dynamic_loader.js new file mode 100644 index 0000000000..a2c23a170e --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/js/dynamic_loader.js @@ -0,0 +1,48 @@ +(function ($, Drupal, drupalSettings) { + Drupal.behaviors.bosSearchDynamicLoader = { + attach: function (context, settings) { + + var scriptPath = drupalSettings.bos_search.dynamic_script; + var cssPath = drupalSettings.bos_search.dynamic_style; + + // Function to load a JavaScript file + function loadScript(url) { + var script = document.createElement('script'); + script.src = url; + document.head.appendChild(script); + } + + // Function to load a CSS file + function loadCSS(url) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + document.head.appendChild(link); + } + + // Load resources if they are defined + const element = $('.aienabledsearchbutton', context); + if (element.length > 0 && !element.attr('data-once-loadPresetJS')) { + element.attr('data-once-loadPresetJS', true); + if (scriptPath) { + loadScript(scriptPath); + } + if (cssPath) { + loadCSS(cssPath); + } + } + else { + const element = $('.aienabledsearchform', context); + if (element.length > 0 && !element.attr('data-once-loadPresetJS')) { + element.attr('data-once-loadPresetJS', true); + if (scriptPath) { + loadScript(scriptPath); + } + if (cssPath) { + loadCSS(cssPath); + } + } + } + } + }; +})(jQuery, Drupal, drupalSettings); diff --git a/docroot/modules/custom/bos_components/modules/bos_search/js/modal_close.js b/docroot/modules/custom/bos_components/modules/bos_search/js/modal_close.js new file mode 100644 index 0000000000..cf81243af9 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/js/modal_close.js @@ -0,0 +1,44 @@ +(function ($, Drupal, once) { + Drupal.behaviors.modal_close = { + attach: function (context, settings) { + once('modal_close', '#drupal-modal .modal-close', context).forEach( + function (element) { + $(element).click(function (event) { + Drupal.dialog("#drupal-modal").close(); + }); + } + ); + once('modal_reset', '.aienabledsearchform .ai-form-reset', context).forEach( + function (element) { + $(element).click(function (event) { + // $('.bos-search-aisearchform').removeClass('no-welcome'); + var searchform = $('.aienabledsearchform'); + searchform.find('[name=session_id]').val(""); + searchform.find('#search-conversation-wrapper') + .fadeOut('fast', function(){ + searchform.find('#search-conversation-wrapper').empty().show(); + searchform.find('#edit-welcome').slideDown('fast'); + searchform.removeClass("has-results"); + searchform.find("input.search-bar").removeAttr('disabled').focus(); + }); + }); + } + ); + once('resetAi', '.aienabledsearchform', context).forEach( + function(element){ + $(document).on("ajaxComplete", function(event, xhr, settings) { + var searchform = $('.aienabledsearchform'); + if (xhr.statusText.toString() === 'success' && + drupalSettings.has_results) { + if (!searchform.hasClass('has-results')) { + searchform + .addClass('has-results') + } + } + }); + } + ); + + }, + }; +})(jQuery, Drupal, once); diff --git a/docroot/modules/custom/bos_components/modules/bos_search/readme.md b/docroot/modules/custom/bos_components/modules/bos_search/readme.md new file mode 100644 index 0000000000..54b27f2aad --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/readme.md @@ -0,0 +1,2 @@ +lando drush pmu bos_search bos_gc_aisearch_plugin bos_aws_services +lando drush en bos_search bos_gc_aisearch_plugin bos_aws_services diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearch.php b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearch.php new file mode 100644 index 0000000000..3d51b067a0 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearch.php @@ -0,0 +1,270 @@ +hasValue("preset") ?: FALSE) { + return $form_state->getValue("preset"); + } + + // Get the preset from the request object. + $request = \Drupal::request(); + if ($request->query->has('preset')) { + return $request->query->get('preset'); + } + + // If node is present. + if ($node) { + $preset = $_SESSION['bos_search']['block_preset'][$node->id()] ?: FALSE; + if ($preset) { + return $preset; + } + else { + $preset = self::getNodeBlock(['aienabledsearchbutton', 'aienabledsearchform']); + if(is_string($preset)) { + $_SESSION['bos_search']['block_preset'][$node->id()] = $preset; + return $preset; + } + } + } + + // Return the first preset as a default. + return array_key_first(self::getPresets()); + + } + + /** + * Fetch the preset (set on Search Config Form) + * + * @param string $preset_name + * + * @return array + */ + public static function getPresetValues(string $preset_name = ""): array { + if ($preset_name == "") { + $preset_name = self::getPreset(); + } + $config = \Drupal::config("bos_search.settings")->get("presets"); + if (empty($preset_name)) { + return []; + } + else { + return $config[$preset_name] ?? []; + } + } + + /** + * Get an Assoc Array with all presets listed. + * This format is suitable for options in select form objects. + * + * @return array + */ + public static function getPresets(): array { + $config = \Drupal::config("bos_search.settings")->get("presets") ?? []; + $output = []; + foreach ($config as $cid => $preset) { + $output[$cid] = $preset["name"]; + } + return $output; + } + + /** + * Creates a new string from a string. + * The new string can be used as a valid drupal machine id. + * + * @param string $name + * + * @return string + */ + public static function machineName(string $name):string { + return strtolower(preg_replace('/[^a-zA-Z0-9_]+/', '_', $name)); + } + + /** + * Cleans up a string. + * + * @param $string string the string to be cleaned + * + * @return string the cleaned string + */ + public static function sanitize(string $string): string { + // TODO: Do we want to add profanity filters or other forms of sanitation here? + return (trim($string)); + } + + /** + * Scans the Templates folder and gets a list of implemented themes (subfolders) + * for the main search form. + * + * @return array + */ + public static function getFormThemes(): array { + $folders = glob(\Drupal::service("extension.list.module")->getPath('bos_search') . "/templates/presets/*", GLOB_ONLYDIR); + $themes = []; + foreach($folders as $folder) { + $folder = basename($folder); + $themes[$folder] = ucwords(str_replace(["_", "-"], " ", $folder)); + } + return $themes; + } + + /** + * Scans the provided folder's 'presets' subfolder and gets a list of + * implemented templates to be used for the overall search theme for the + * main search form. + * + * The array has an index with the filename stripped of "html.twig" extension + * with "-" replacing underscores in the filename. + * The array values are a generated human-readable name for the filename by + * replacing all underscores spaces. + * + * @param string $theme The folder to scan + * + * @return array an assoc array of templates. + */ + public static function getFormTemplates(string $theme): array { + $files = glob(\Drupal::service("extension.list.module")->getPath('bos_search') . "/templates/presets/{$theme}/*.html.twig"); + $templates = []; + foreach($files as $file) { + $twig = basename($file); + $template = str_replace(".html.twig", "", $twig); + $templates[$template] = ucwords(str_replace(["_", "-"], " ", $template)); + } + return $templates; + } + + public static function isBosSearchThemed(): bool { + + // Is this the disclaimer form? + if (\Drupal::request()->attributes->get("_route") == "bos_search.open_DisclaimerForm") { + return TRUE; + } + + // Is this the AISearch form? + if (\Drupal::request()->attributes->get("_route") == "bos_search.open_AISearchForm") { + return TRUE; + } + + // Is this the AISearch Config form? + if (\Drupal::request()->attributes->get("_route") == "bos_search.AiSearchConfigForm") { + return TRUE; + } + + // If this is a node, check if the node has a block displayed within it. + if (!empty(\Drupal::request()->attributes->get("node"))) { + return self::hasNodeBlock(['aienabledsearchbutton', 'aienabledsearchform']); + } + + // Don't appear to need the bos_search theme, return false. + return FALSE; + } + + /** + * Report if the current node will display any blocks which have been created + * and placed based on the supplied $targetblock definitions. + * + * @param array $targetblocks + * + * @return bool + * + */ + public static function hasNodeBlock(array $targetblocks) { + return !self::getNodeBlock($targetblocks) === FALSE; + } + + /** + * Determine if the current node will show any blocks which implement any of + * the $targetblock defintions. + * If so, return the blocks preset if it has one, or else TRUE. If not return FALSE. + * + * @param array $targetblocks + * + * @return bool | string FALSE in not blocks found, or else the blocks preset if it has one, or else TRUE. + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public static function getNodeBlock(array $targetblocks) { + + // First find all the blocks created and placed using the block templates. + $blocks = \Drupal::entityTypeManager()->getStorage('block')->getQuery() + ->accessCheck(TRUE); + $or_group = $blocks->orConditionGroup(); + foreach($targetblocks as $targetblock) { + $or_group = $or_group->condition('id', $targetblock, 'CONTAINS'); + } + $blocks->condition($or_group); + $blocks = $blocks->execute(); + + // Now see if the block is configured to display on this node. + foreach($blocks as $blockname) { + $block = \Drupal::entityTypeManager() + ->getStorage('block') + ->load($blockname); + foreach ($block->getVisibilityConditions() as $condition) { + if ($condition->evaluate()) { + // Soon as you find a matching condition return. + $settings = $block->get("settings") ?: []; + if (!empty($settings["aisearch_config_preset"])) { + return $settings["aisearch_config_preset"]; + } + return TRUE; + } + } + } + + return FALSE; + + } + + + /** + * Sets a custom session cookie. + * + * @param string $key + * The key used to store the value in the session. + * @param string|bool|array $value + * The value to store in the session, which can be a string, boolean, or array. Defaults to TRUE. + * NOTE: Bool values are coerced into an integer (0=false, 1=true) + * + * @return void + * Does not return any value. + */ + public static function setSessionCookie(string $key, string|bool|array $value = TRUE):void { + // Set a custom session cookie. + if (session_status() == PHP_SESSION_NONE) { + session_start(); + } + if (is_array($value)) { + $value = serialize($value); + } + $_SESSION[$key] = base64_encode($value); + } + + /** + * Retrieves a custom session cookie. + * + * @param string $key + * The key of the session cookie to retrieve. + * + * @return string|array + * The decoded session cookie (bools converted to int), or FALSE if not set. + */ + public static function getSessionCookie(string $key): string|array { + // Set a custom session cookie. + if (session_status() == PHP_SESSION_NONE) { + session_start(); + } + if (empty($_SESSION['shown_search_disclaimer'])) { + return FALSE; + } + // return FALSE; + return base64_decode($_SESSION['shown_search_disclaimer']); + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchBase.php b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchBase.php new file mode 100644 index 0000000000..462094c739 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchBase.php @@ -0,0 +1,92 @@ +pluginDefinition['service']; + } + + /** + * @inheritDoc + */ + public function getService(): GcAgentBuilderInterface|GcServiceInterface { + if (empty($this->service)) { + $serviceid = $this->getServiceId(); + $this->service = \Drupal::getContainer()->get($serviceid); + } + return $this->service; + } + + public function __construct(array $configuration, $plugin_id, $plugin_definition) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->getService(); + } + + /** + * Reformats the metadata array into a better format for rendering in twig. + * + * @param array $metadata + * @param array $map + * @param array $exclude_elem + * + * @return void + */ + protected function flattenMetadata(array &$metadata, array $map = [], array $exclude_elem = []):array { + + foreach ($metadata as $key => &$elem) { + if (is_array($elem)) { + $elem = $this->flatten_md($elem, $map, $exclude_elem); + } + else { + $key = ucwords(str_replace("_", " ", $key)); + $metadata[$key] = $elem; + } + } + return $metadata; + + } + + private function flatten_md(array $elements, array $map = [], array $exclude_elem = [], string $prefix = ''):?array { + + $output = []; + + foreach ($elements as $key => $value) { + + if ($value !== NULL) { + + $key = ($map[$key] ?? $key); + $title = empty($prefix) ? $key : "$prefix.$key"; + + if (!in_array($title, $exclude_elem)) { + + if (is_array($value)) { + $output = array_merge($output, $this->flatten_md($value, $map, $exclude_elem, $title) ?: []); + } + else { + $metatitle = str_replace(" ", "", ucwords(str_replace("_", " ", $title))); + $output[$title] = [ + "key" => $metatitle, + "value" => $value, + ]; + } + } + } + } + + return (empty($output) ? NULL : $output); + + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchFormCallbacks.php b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchFormCallbacks.php new file mode 100644 index 0000000000..40e85ea1f7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchFormCallbacks.php @@ -0,0 +1,89 @@ +entityTypeManager = $entity_type_manager; + $this->form_builder = $form_builder; + } + + /** + * {@inheritdoc} + */ + public static function trustedCallbacks() { + return ['renderSearchForm']; + } + + /** + * Lazy builder callback for switch-back link. + * + * @return array|string + * Render array or an emty string. + */ + public function renderSearchForm(?string $preset = NULL) { //(string $title = "", ?string $preset = NULL) { + + $form = $this->form_builder->getForm('Drupal\bos_search\Form\AiSearchForm', $preset); + + // Enable the disclaimer if required by preset. +// $preset = $form["AiSearchForm"]["content"]["preset"]["#value"] ?: $preset; + $config = AiSearch::getPresetValues($preset); + + if ($config && $config["searchform"]['disclaimer']['enabled']) { + + // Check if disclaimer should be shown. + if (($config["searchform"]['disclaimer']['show_once'] && !AiSearch::getSessionCookie('shown_search_disclaimer')) + || !$config["searchform"]['disclaimer']['show_once']) { + + // Add in the js to show the modal, plus drupalSettings it needs. + $form['#attached']['library'][] = 'bos_search/disclaimer'; + $form['#attached']['drupalSettings']['disclaimerForm'] = [ + 'openModal' => Url::fromRoute('bos_search.open_DisclaimerForm') + ->toString(), + 'triggerDisclaimerModal' => TRUE, + ]; + + // Mark the disclaimer session flag. + AiSearch::setSessionCookie('shown_search_disclaimer', TRUE); + } + } + return $form; + + } + /** + * AJAX callback to open the modal disclaimer form - not implemented. + */ + public function ajaxOpenDisclaimerModalForm(array &$form, FormStateInterface $form_state) { + return new AjaxResponse(); + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchInterface.php b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchInterface.php new file mode 100644 index 0000000000..d10bc4fcc0 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/AiSearchInterface.php @@ -0,0 +1,58 @@ +formBuilder = $formBuilder; + } + + /** + * {@inheritdoc} + * + * @param \Symfony\Component\DependencyInjection\ContainerInterface $container + * The Drupal service container. + * + * @return static + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('form_builder') + ); + } + + /** + * Callback for opening the modal form. + */ + public function openModalForm(): AjaxResponse { + + $config = AiSearch::getPresetValues(); + + if ($config && $config["searchform"]['disclaimer']['enabled']) { + // Check if disclaimer should be shown. + if (($config["searchform"]['disclaimer']['show_once'] && !AiSearch::getSessionCookie('shown_search_disclaimer')) + || !$config["searchform"]['disclaimer']['show_once']) { + + // Show the interstitial (modal) disclaimer + $response = $this->openDisclaimerForm(AiSearch::getPreset()); + AiSearch::setSessionCookie('shown_search_disclaimer', TRUE); + return $response; + + } + } + + $response = new AjaxResponse(); + + // Get the modal form using the form builder. + $modal_form = $this->formBuilder->getForm('Drupal\bos_search\Form\AiSearchForm'); + + // Ensure we have a preset in the search element. + if (empty($modal_form["AiSearchForm"]["content"]["preset"])) { + $preset = AiSearch::getPreset(); + $search_preset = [ + "#default_value" => $preset, + "#value" => $preset, + ]; + $modal_form["AiSearchForm"]["search"]["preset"] = $modal_form["AiSearchForm"]["content"]["preset"] + $search_preset; + } + + // Add an AJAX command to open a modal dialog with the form as the content. + $ui_options = [ + 'width' => '85%', + 'maxWidth' => '85%', + "classes" => [ + "ui-dialog" => "aisearch-modal-form ui-corner-all aienabledsearchform" + ], + "closeOnEscape" => TRUE, + 'closeText' => "Close this window", + ]; + if (empty($modal_form["#modal_title"])) { + $ui_options["classes"]["ui-dialog-titlebar"] = "ui-titlebar-hidden"; + } + $response->addCommand(new OpenModalDialogCommand(($modal_form["#modal_title"] ?? ""), $modal_form, $ui_options)); + unset($modal_form["#modal_title"]); + + return $response; + } + + public function openDisclaimerForm(): AjaxResponse { + $response = new AjaxResponse(); + $modal_form = $this->formBuilder->getForm('Drupal\bos_search\Form\AiDisclaimerForm'); + // Add an AJAX command to open a modal dialog with the form as the content. + $rendered_form = \Drupal::service('renderer')->render($modal_form); + $ui_options = [ + 'width' => '591px', + "classes" => [ + "ui-dialog" => "aisearch-disclaimer-form ui-corner-all aienableddisclaimerform" + ], + "closeOnEscape" => TRUE, + 'closeText' => "Close this window", + ]; + if (empty($modal_form["#modal_title"])) { + $ui_options["classes"]["ui-dialog-titlebar"] = "ui-titlebar-hidden"; + } + $response->addCommand(new OpenModalDialogCommand( + ($modal_form["#modal_title"] ?: ""), + $rendered_form, + $ui_options + )); + + AiSearch::setSessionCookie('shown_search_disclaimer', TRUE); + return $response; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Controller/AutocompleteController.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Controller/AutocompleteController.php new file mode 100644 index 0000000000..0d0a1c27d4 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Controller/AutocompleteController.php @@ -0,0 +1,50 @@ +query->get('q'); + + if ($input && strlen($input) > 3) { + + $nids = \Drupal::entityQuery('node') + ->condition('title', $input, 'CONTAINS') + ->sort('created', 'DESC') + ->accessCheck(0) + ->execute(); + $nids = array_slice($nids, 0, 10); + $nodes = Node::loadMultiple($nids); + + foreach ($nodes as $node) { + $titles[] = [ + 'value' => "{$node->toUrl()->toString()}", + 'label' => "{$node->getTitle()}", + 'entity_id' => $node->id(), + 'description' => "{$node->getTitle()} ({$node->toUrl()->toString()})", + ]; + } + } + + return new JsonResponse($titles); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiDisclaimerForm.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiDisclaimerForm.php new file mode 100644 index 0000000000..e4172309b2 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiDisclaimerForm.php @@ -0,0 +1,78 @@ + ["library" => ["bos_search/core"]], + '#modal_title' => $config["searchform"]["modal_titlebartitle"] ?? "", + '#theme' => "disclaimer__{$config["searchform"]["theme"]}", + 'notice' => [ + "#markup" => Markup::create($config["searchform"]["disclaimer"]["text"]), + ], + 'actions' => [ + 'submit' => [ + '#type' => 'submit', + '#value' => 'Continue', + '#attributes' => [ + "class" => [ + "btn-submit" + ], + ], + ], + 'cancel' => [ + '#type' => 'button', + '#value' => 'Cancel', + '#access' => FALSE, + '#attributes' => [ + "class" => [ + "btn-cancel" + ], + ], + ], + ], + ]; + + return $form; + + } + + /** + * @inheritDoc + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // Not required. + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchConfigForm.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchConfigForm.php new file mode 100644 index 0000000000..220dac4234 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchConfigForm.php @@ -0,0 +1,1071 @@ +pluginManagerAiSearch = $plugin_manager_aisearch; + } + + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('plugin.manager.aisearch'), + $container->get('config.typed') + ); + } + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'bos_search_SearchConfigForm'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames(): array { + return ["bos_search.settings"]; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $presets = AiSearch::getPresets(); + $form = [ + 'SearchConfigForm' => [ + '#tree' => TRUE, + '#type' => 'fieldset', + '#title' => 'AI Search Configuration', + 'presets' => [ + "#type" => "fieldset", + '#title' => "presets", + '#attributes' => ["id" => "edit-presets"], + '#description_display' => 'before', + '#description' => "You can define any number of presets and use these in search form implementations.", + ], + 'actions' => [ + 'add' => [ + "#type" => "button", + "#value" => "Add Preset", + '#ajax' => [ + 'callback' => '::ajaxAddPreset', + 'event' => 'click', + 'wrapper' => 'edit-presets', + 'disable-refocus' => FALSE, + 'limit' => FALSE + ] + ] + ] + ], + ]; + + // Get and populate each existing preset. + foreach($presets as $pid => $preset) { + $form['SearchConfigForm']['presets'][$pid] = $this->preset($pid); + } + + if ($form_state->isRebuilding()) { + // A rebuild does occur when an ajax button is clicked. + if ($form_state->getTriggeringElement()["#value"] == $form["SearchConfigForm"]["actions"]["add"]["#value"]) { + // The ajax "Add Preset" button has been clicked. + $this->addPreset($form, $form_state); + } + elseif ($form_state->getTriggeringElement()["#value"] == "Delete Preset") { + // An ajax "Delete Preset" button has been clicked. + $this->deletePreset($form, $form_state); + } + } + + $form = parent::buildForm($form, $form_state); + return $form; + + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + // Save the presets and any other fields to the settings config object. + $values = $form_state->getUserInput(); + $config = $this->config('bos_search.settings'); + $params = []; + foreach($values["SearchConfigForm"]["presets"] as &$preset) { + if (empty($preset['pid'])) { + $preset['pid'] = AiSearch::machineName($preset["name"]); + } + unset($preset["actions"]); + $params[$preset['pid']] = $preset; + } + $config->set("presets", $params); + $config->save(); + parent::submitForm($form, $form_state); // optional + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + parent::validateForm($form, $form_state); + + $values = $form_state->getValues(); + + foreach($values["SearchConfigForm"]["presets"] as $preset => $setting) { + $plugin = $this->pluginManagerAiSearch->createInstance($setting["plugin"]); + if (!$plugin->hasFollowup()) { + // TODO: should introduce a no-conversation version of the AiSearch component. + $form_state->setError($form["SearchConfigForm"]["presets"][$preset]["plugin"],"The selected Service ($preset) does not support conversations."); + } + } + + } + + /** + * Callback for Add Preset button on form. + * + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + * + * @return mixed + */ + public function ajaxAddPreset(array &$form, FormStateInterface $form_state) { + // The buildForm will have been called twice by this time, once as a build, + // and once as a rebuild. + return $form['SearchConfigForm']['presets']; + } + + /** + * Add a New preset to the form object. + * NOTE: nothing is saved until the config form is saved (submitted) + * + * @param $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + * + * @return void + */ + public function addPreset(&$form, FormStateInterface $form_state) { + $pid = count($form_state->getValues()['SearchConfigForm']['presets'] ?? []); + $form['SearchConfigForm']['presets'][$pid] = $this->preset(); + $rand = intval(microtime(TRUE) * 1000); + foreach($form['SearchConfigForm']['presets'][$pid] as $key => &$preset) { + if (!str_contains($key, "#")) { + $preset["#id"] = "edit-searchconfigform-presets-$pid-$key--$rand"; + $preset["#attributes"] = [ + "data-drupal-selector" => "edit-searchconfigform-presets-$pid-$key", + ]; + } + } + $form['SearchConfigForm']['presets'][$pid]["#title"] = "New Preset"; + $form['SearchConfigForm']['presets'][$pid]["#open"] = TRUE; + $form['SearchConfigForm']['presets'][$pid]["#id"] = "edit-searchconfigform-presets-$pid--$rand"; + $form['SearchConfigForm']['presets'][$pid]["#attributes"] = [ + "data-drupal-selector" => "edit-searchconfigform-presets-$pid", + ]; + } + + /** + * Callback for Delete Preset button on form. + * + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + * + * @return mixed + */ + + public function ajaxDeletePreset(array &$form, FormStateInterface $form_state) { + return $form['SearchConfigForm']['presets']; + } + + /** + * Delete the selected Preset. + * NOTE: nothing is actually deleted until the config form is saved (submitted) + * + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + * + * @return void + */ + public function deletePreset(array &$form, FormStateInterface $form_state) { + $pid = $form_state->getTriggeringElement()['#attributes']['data-pid']; + unset($form['SearchConfigForm']['presets'][$pid]); + } + + /** + * Defines a preset fieldset on the form. + * + * @param string $pid + * + * @return array + */ + private function preset(string $pid = "") { + + $preset = AiSearch::getPresetValues($pid) ?? []; + + /** + * @var $service_plugins \Drupal\bos_search\Plugin\AiSearch\AiSearchPluginManager Registered AI Search Service Plugins + */ + $service_plugins = $this->pluginManagerAiSearch->getDefinitions(); + // Populate an array of service plugins. + $service_opts = []; + foreach($service_plugins as $service_plugin) { + $service_opts[$service_plugin["id"]] = $service_plugin["id"]; + } + // Get info on the service this preset is referencing. + $this_service_plugin = $service_plugins[$preset["plugin"]]; + $this_service_id = $this_service_plugin["service"]; + $this_service = \Drupal::service($this_service_id); + $this_service_settings = $this_service->getSettings(); + + $project_name = $this->getProjects($this_service)[$this_service_settings["project_id"]]; + + $themes = AiSearch::getFormThemes(); + + $output = [ + '#type' => 'details', + '#title' => (empty($preset) ? "": $preset['name']) . (empty($preset) ? "" : " (". $this_service->id() .")"), + '#open' => FALSE, + + 'name' => [ + '#type' => 'textfield', + '#required' => TRUE, + '#title' => $this->t("Preset Name"), + "#default_value" => empty($preset) ? "" : ($preset['pid'] ?? ""), + '#placeholder' => "Enter the name for this preset", + ], + 'plugin' => [ + '#type' => 'select', + '#options' => $service_opts, + "#default_value" => empty($preset) ? "" : ($preset['plugin'] ?? "") , + '#title' => $this->t("Select the AI Service Plugin to use:"), + '#ajax' => [ + 'callback' => '::ajaxCallbackChangedModel', + 'progress' => [ + 'type' => 'throbber', + 'message' => "Reloading default configs ..." + ] + ], + ], + 'prompt' => [ + '#type' => 'select', + '#options' => $this->getPrompts($this_service), + "#default_value" => empty($preset) ? "" : ($preset['prompt'] ?? "") , + '#title' => $this->t("Select the prompt for the AI Model to use:"), + '#description' => $this->t("Prompts are set from the admin page for the model selected."), + '#description_display' => 'after', + '#prefix' => "
", + '#suffix' => "
", + ], + 'model_tuning' =>[ + '#type' => "details", + '#title' => "Advanced AI Model Tuning", + + 'overrides' => [ + '#type' => "fieldset", + '#title' => 'Service Plugin Override', + '#description' => $this->t("The default Service Settings are set on the Google Cloud Conversation configuration page."), + '#description_display' => 'before', + + 'service_account' => [ + '#type' => 'select', + '#options' => $this->getServiceAccounts($this_service), + "#default_value" => empty($preset) ? "default" : ($preset['model_tuning']['overrides']['service_account'] ?? "default") , + '#title' => $this->t("Override the default Service Account for the AI Model to use:"), + '#description' => $this->t("The current default Service Account is: {$this_service_settings["service_account"]}"), + '#description_display' => 'after', + '#prefix' => "
", + '#suffix' => "
", + '#validated' => TRUE, + '#ajax' => [ + 'callback' => '::ajaxCallbackGetServiceAccount', + 'event' => 'focus', + 'progress' => [ + 'type' => 'throbber', + 'message' => "Finding Service Accounts ..." + ] + ], + + ], + + 'project_id' => [ + '#type' => 'select', + '#options' => $this->getProjects($this_service, ($preset['model_tuning']['overrides']['service_account'] ?? "default")), + "#default_value" => empty($preset) ? "" : ($preset['model_tuning']['overrides']['project_id'] ?? ""), + '#title' => $this->t("Override the Project for the AI Model to use."), + '#description' => $this->t("Leave empty to use the default.
The current default Project is: $project_name"), + '#description_display' => 'after', + '#validated' => TRUE, + '#prefix' => "
", + '#suffix' => "
", + '#ajax' => [ + 'callback' => '::ajaxCallbackGetProjects', + 'event' => 'click', + 'progress' => [ + 'type' => 'throbber', + 'message' => "Finding Projects ..." + ] + ], + ], + 'datastore_id' => [ + '#type' => 'select', + '#options' => $this->getDatastores($this_service, ($preset['model_tuning']['overrides']['service_account'] ?? "default"), ($preset['model_tuning']['overrides']['project_id'] ?? "default")), + "#default_value" => empty($preset) ? "default" : ($preset['model_tuning']['overrides']['datastore_id'] ?? "default") , + '#title' => $this->t("Override the default Datastore for the AI Model to use:"), + '#description' => $this->t("The current default dataStore is: {$this_service_settings["datastore_id"]}"), + '#description_display' => 'after', + '#validated' => TRUE, + '#prefix' => "
", + '#suffix' => "
", + '#ajax' => [ + 'callback' => '::ajaxCallbackGetDataStores', + 'event' => 'focus', + 'progress' => [ + 'type' => 'throbber', + 'message' => "Finding Datastores ..." + ] + ], + ], + 'engine_id' => [ + '#type' => 'select', + '#options' => $this->getEngines($this_service, ($preset['model_tuning']['overrides']['service_account'] ?? "default"), ($preset['model_tuning']['overrides']['project_id'] ?? "default")), + "#default_value" => empty($preset) ? "default" : ($preset['model_tuning']['overrides']['engine_id'] ?? "default") , + '#title' => $this->t("Override the default Engine for the AI Model to use:"), + '#description' => $this->t("The current default engine is: {$this_service_settings["engine_id"]}"), + '#description_display' => 'after', + '#validated' => TRUE, + '#prefix' => "
", + '#suffix' => "
", + '#ajax' => [ + 'callback' => '::ajaxCallbackGetEngines', + 'event' => 'focus', + 'progress' => [ + 'type' => 'throbber', + 'message' => "Finding Engines ..." + ] + ], + ], + ], + 'summary' => [ + '#type' => "fieldset", + '#title' => 'Fine-tune Summarization', + 'ignoreAdversarialQuery' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['summary']['ignoreAdversarialQuery'] ?? 0), + '#title' => $this->t("Ignore Adverserial Queries."), + '#description' => 'When selected, no summary is returned if the search query is classified as an adversarial query. For example, a user might ask a question regarding negative comments about the company or submit a query designed to generate unsafe, policy-violating output.' + ], + 'ignoreNonSummarySeekingQuery' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['summary']['ignoreNonSummarySeekingQuery'] ?? 0), + '#title' => $this->t("Ignore Non-summary Seeking Queries."), + '#description' => 'When selected, no summary is returned if the search query is classified as a non-summary seeking query. For example, why is the sky blue and Who is the best soccer player in the world? are summary-seeking queries, but SFO airport and world cup 2026 are not.' + ], + 'ignoreLowRelevantContent' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['summary']['ignoreLowRelevantContent'] ?? 0), + '#title' => $this->t("Ignore Low Relevant Content."), + '#description' => 'When selected, only queries with high relevance search results will generate answers.' + ], + 'ignoreJailBreakingQuery' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['summary']['ignoreJailBreakingQuery'] ?? 0), + '#title' => $this->t("Ignore Jail-breaking Queries."), + '#description' => "When selected, search-query classification is applied to detect queries that attempts to exploit vulnerabilities or weaknesses in the model's design or training data. No summary is returned if the search query is classified as a jail-breaking query." + ], + 'semantic_chunks' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['model_tuning']['summary']['semantic_chunks'] ?? 0), + '#title' => $this->t("Enable Semantic Chunk Search."), + '#description' => 'When selected, the summary will be generated from most relevant chunks from top search results. This feature will improve summary quality. Note that with this feature enabled, not all top search results will be referenced and included in the reference list, so the citation source index only points to the search results listed in the reference list.' + ], + ], + 'search' => [ + '#type' => "fieldset", + '#title' => 'Fine-tune Search', + 'safe_search' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 1 : ($preset['model_tuning']['search']['safe_search'] ?? 0), + '#title' => $this->t("Enable Safe Search."), + '#description' => 'When selected, significantly reduces the level of explicit content that the system can display in the results. This is similar to the feature used in Google Search, where you can modify your settings to filter explicit content, such as nudity, violence, and other adult content, from the search results.' + ], + ], + ], + 'searchform' => [ + '#type' => 'details', + '#collapsible' => TRUE, + '#title' => 'Search Form Configuration and Styling', + 'theme' => [ + '#type' => 'select', + '#options' => $themes, + "#default_value" => empty($preset) ? "" : ($preset['results']['theme'] ?? "") , + '#title' => $this->t("Select the theme for the form configured by this preset"), + ], + 'disclaimer' => [ + '#type' => 'fieldset', + '#title' => $this->t("Pop-up Disclaimer"), + '#description' => $this->t("Control the presence and content of an interstitial disclaimer which shows before the search form is shown."), + '#description_display' => 'before', + 'enabled' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['searchform']['disclaimer']['enabled'] ?? 0), + '#title' => $this->t("Show disclaimer window"), + ], + 'show_once' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['searchform']['disclaimer']['show_once'] ?? 0), + '#title' => $this->t("Only Show Once"), + '#description' => $this->t("When checked, the disclaimer window will only appear the first time the search form loads, when unselected the disclaimer window will show every time the search form opens for the user. This is a session-based rule."), + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][disclaimer][enabled]"]' => ['checked' => TRUE], + ], + ], + ], + 'text' => [ + '#type' => 'textarea', + "#default_value" => empty($preset) ? "" : ($preset['searchform']['disclaimer']['text'] ?? ""), + '#title' => $this->t("Popup Disclaimer"), + '#description' => $this->t("Disclaimer text to appear as an interstitial popup when first showing the form."), + '#description_display' => 'before', + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][disclaimer][enabled]"]' => ['checked' => TRUE], + ], + 'required' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][disclaimer][enabled]"]' => ['checked' => TRUE], + ], + ], + ], + ], + 'modal_titlebartitle' => [ + '#type' => 'textfield', + '#title' => $this->t("Modal Form Title"), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['modal_titlebartitle'] ?? ""), + '#description' => $this->t("Leave blank for no title on the search form when it is a modal window."), + '#description_display' => 'before', + ], + 'welcome' => [ + '#type' => 'fieldset', + '#title' => $this->t("Main Form Body"), + '#description' => $this->t("Configure the initial information displayed to the user"), + '#description_display' => 'before', + 'body_title' => [ + '#type' => 'textfield', + '#title' => $this->t("Form Body Title"), + "#default_value" => empty($preset) ? 0 : ($preset['searchform']['welcome']['body_title'] ?? ""), + '#placeholder' => "What are you looking for?", + '#description' => $this->t("Add a title for the search form. Can be blank."), + '#description_display' => 'after', + ], + 'body_text' => [ + '#type' => 'textarea', + '#title' => $this->t("Form Body Copy"), + "#default_value" => empty($preset) ? 0 : ($preset['searchform']['welcome']['body_text'] ?? ""), + '#description' => $this->t("Add follow-on/body copy to appear on the search form. Can be blank."), + '#description_display' => 'before', + ], + 'cards' =>[ + '#type' => 'fieldset', + '#title' => $this->t("Example/Suggested Searches"), + '#description' => $this->t("Example search terms presented as cards"), + 'enabled' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['searchform']['welcome']['cards']['enabled'] ?? 0), + '#title' => $this->t("Enable cards."), + ], + 'card_1' => [ + '#type' => 'textfield', + '#title' => $this->t("Example Question 1"), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['welcome']["cards"]['card_1'] ?? ""), + '#placeholder' => "How do I open a new business in Boston?", + '#description' => $this->t("Enter text for the example question to place in the card."), + '#description_display' => 'after', + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE], + ], + 'required' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE], + ], + ], + ], + 'card_2' => [ + '#type' => 'textfield', + '#title' => $this->t("Example Question 2"), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['welcome']["cards"]['card_2'] ?? ""), + '#placeholder' => "When is the next meeting for the small business forum?", + '#description' => $this->t("Enter text for the example question to place in the card."), + '#description_display' => 'after', + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE], + ], + 'required' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE], + ], + ], + ], + 'card_3' => [ + '#type' => 'textfield', + '#title' => $this->t("Example Question 3"), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['welcome']["cards"]['card_3'] ?? ""), + '#placeholder' => "How do I become a certified Boston Equity Applicant?", + '#description' => $this->t("Enter text for the example question to place in the card."), + '#description_display' => 'after', + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE], + ], + 'required' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][welcome][cards][enabled]"]' => ['checked' => TRUE], + ], + ], + ], + ], + ], + + 'searchbar' => [ + '#type' => 'fieldset', + '#title' => $this->t("Searchbar Configuration"), + '#description' => $this->t("Display settings for the main search bar"), + '#description_display' => 'before', + 'allow_conversation' => [ + '#type' => 'checkbox', + '#title' => $this->t("Allow follow-on questions during search."), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['allow_reset'] ?? 0), + ], + 'allow_reset' => [ + '#type' => 'checkbox', + '#title' => $this->t("Allow the user to reset the search history"), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['allow_reset'] ?? 0), + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][searchform][searchbar][allow_conversation]"]' => ['checked' => TRUE], + ], + ], + ], + 'search_text' => [ + '#type' => 'textfield', + '#title' => $this->t("Search Prompt"), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['search_text'] ?? ""), + '#placeholder' => "How can we help you ?" + ], + 'waiting_text' => [ + '#type' => 'textfield', + '#title' => $this->t("Text to show in searchbar when waiting for search results"), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['waiting_text'] ?? ""), + '#placeholder' => "Searching Boston.gov?" + ], + 'audio_search_input' => [ + '#type' => 'checkbox', + '#title' => $this->t("Allow Audio input to searchbar"), + "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['audio_search_input'] ?? 0), + ], + 'search_note' => [ + '#type' => 'textarea', + "#default_value" => empty($preset) ? "" : ($preset['searchform']['searchbar']['search_note'] ?? ""), + '#title' => $this->t("Search Help"), + '#description' => $this->t("Any help notes to appear under the search box. Can be left blank."), + '#description_display' => 'after', + ], + ], + ], + 'results' => [ + '#type' => 'details', + '#collapsible' => TRUE, + '#title' => 'Search Results Configuration', + 'result_count' => [ + '#type' => 'select', + '#options' => [ + 0 => "All", + 1 => "1", + 3 => "3", + 5 => "5", + 10 => "10", + 15 => "15", + 20 => "20", + ], + "#default_value" => empty($preset) ? 0 : ($preset['results']['result_count'] ?? 0) , + '#title' => $this->t("How many results should be returned?"), + ], + 'summary' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['results']['summary'] ?? 0), + '#title' => $this->t("Show AI Model generated summary text in results output."), + ], + 'no_result_text' => [ + '#type' => 'textarea', + "#default_value" => empty($preset) ? "" : ($preset['results']['no_result_text'] ?? ""), + '#title' => $this->t("No Results Text"), + '#description' => $this->t("Text that should appear when the AI Model is unable to answer a question."), + '#description_display' => 'after', + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][results][summary]"]' => ['checked' => TRUE], + ], + ], + ], + 'violations_text' => [ + '#type' => 'textarea', + "#default_value" => empty($preset) ? "" : ($preset['results']['violations_text'] ?? ""), + '#title' => $this->t("Query Violations Text"), + '#description' => $this->t("Text that should appear when the question fed to the AI Model was rejected."), + '#description_display' => 'after', + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][results][summary]"]' => ['checked' => TRUE], + ], + ], + ], + 'citations' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['results']['citations'] ?? 0), + '#title' => $this->t("Show citations in results output (if available)."), + '#states' => [ + 'visible' => [ + ':input[name="SearchConfigForm[presets][' . $pid . '][results][summary]"]' => ['checked' => TRUE], + ], + ], + ], + 'min_citation_relevance' => [ + '#type' => 'select', + '#options' => [ + "0" => "Show All", + "0.3" => "0.3", + "0.5" => "0.5", + "0.6" => "0.6", + "0.7" => "0.7", + "0.75" => "0.75", + "0.8" => "0.8", + "0.85" => "0.85", + "0.9" => "0.9", + "0.95" => "0.95", + ], + "#default_value" => empty($preset) ? 0 : ($preset['results']['min_citation_relevance'] ?? 0) , + '#title' => $this->t("The minimum relevance for sitations to appear in list."), + '#description' => $this->t("References with relevance scores below this number will be suppressed in Citations marked in the Summary."), + '#description_display' => "below", + '#states' => [ + 'visible' => [[ + ':input[name="SearchConfigForm[presets][' . $pid . '][results][citations]"]' => ['checked' => TRUE], + ]], + ], + ], + 'searchresults' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['results']['searchresults'] ?? 0), + '#title' => $this->t("Show search results in results output."), + ], + 'no_dup_citations' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['results']['no_dup_citations'] ?? 0), + '#title' => $this->t("Remove search result links that already appear in the citations listing."), + '#states' => [ + 'visible' => [[ + ':input[name="SearchConfigForm[presets][' . $pid . '][results][citations]"]' => ['checked' => TRUE], + ':input[name="SearchConfigForm[presets][' . $pid . '][results][searchresults]"]' => ['checked' => TRUE], + ]], + ], + ], + 'related_questions' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['results']['related_questions'] ?? 0), + '#title' => $this->t("Show related questions (suggested questions) after query results."), + ], + 'feedback' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['results']['feedback'] ?? 0), + '#title' => $this->t("Show feedback buttons below results output."), + ], + 'metadata' => [ + '#type' => 'checkbox', + "#default_value" => empty($preset) ? 0 : ($preset['results']['metadata'] ?? 0), + '#title' => $this->t("Show AI Model metadata in results output (if available)."), + ], + 'pid' => [ + '#type' => 'hidden', + "#default_value" => $pid, + "#value" => $pid, + ], + ], + 'actions' => [ + '#type' => "button", + '#value' => "Delete Preset", + '#attributes' => [ + "class" => [ + "button--danger" + ], + "data-pid" => "$pid", + ], + '#ajax' => [ + 'callback' => '::ajaxDeletePreset', + 'event' => 'click', + 'wrapper' => 'edit-presets', + 'disable-refocus' => FALSE, + 'limit' => FALSE + ] + ], + ]; + + if (!isset($pid) || $pid == "") { + // Configure for a new Preset. + unset($output["actions"]); + foreach($output as &$row) { + if (is_array($row) && $row["#type"] == "textarea") { + $row["#value"] = ""; + } + } + + } + + if (!empty($preset['model_tuning']['overrides']['service_account']) && $preset['model_tuning']['overrides']['service_account'] != "default") { + $output["model_tuning"]["overrides"]["service_account"]["#value"] = $preset['model_tuning']['overrides']['service_account']; + if (!array_key_exists($preset["model_tuning"]["overrides"]["service_account"],$output["model_tuning"]["overrides"]["service_account"]["#options"] )) { + $output["model_tuning"]["overrides"]["service_account"]["#options"][$preset["model_tuning"]["overrides"]["service_account"]] = $preset["model_tuning"]["overrides"]["service_account"]; + } + } + + if (!empty($preset['model_tuning']['overrides']['datastore_id']) && $preset['model_tuning']['overrides']['datastore_id'] != "default") { + $output["model_tuning"]["overrides"]["datastore_id"]["#value"] = $preset['model_tuning']['overrides']['datastore_id']; + if (!array_key_exists($preset["model_tuning"]["overrides"]["datastore_id"],$output["model_tuning"]["overrides"]["datastore_id"]["#options"] )) { + $output["model_tuning"]["overrides"]["datastore_id"]["#options"][$preset["model_tuning"]["overrides"]["datastore_id"]] = $preset["model_tuning"]["overrides"]["datastore_id"]; + } + } + + if (!empty($preset['model_tuning']['overrides']['engine_id']) && $preset['model_tuning']['overrides']['engine_id'] != "default") { + $output["model_tuning"]["overrides"]["engine_id"]["#value"] = $preset['model_tuning']['overrides']['engine_id']; + if (!array_key_exists($preset["model_tuning"]["overrides"]["engine_id"],$output["model_tuning"]["overrides"]["engine_id"]["#options"] )) { + $output["model_tuning"]["overrides"]["engine_id"]["#options"][$preset["model_tuning"]["overrides"]["engine_id"]] = $preset["model_tuning"]["overrides"]["engine_id"]; + } + } + + return $output; + + } + + /** + * Handles AJAX callbacks for getting the service account for the form. + * + * Service Accounts are defined via the bos_google_cloud config form. + * + * @param array $form + * The form array. + * @param FormStateInterface $form_state + * The current state of the form. + * + * @return AjaxResponse + * The response containing the updated service account selection options. + */ + public function ajaxCallbackGetServiceAccount(array $form, FormStateInterface $form_state): AjaxResponse { + $trigger = $form_state->getTriggeringElement(); + $active_preset_id = $trigger["#parents"][2]; + $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id]; + // Find the selected service and its prompts + $service_plugins = $this->pluginManagerAiSearch->getDefinitions(); + $this_service_plugin = $service_plugins[$active_preset["plugin"]]; + $this_service = \Drupal::service($this_service_plugin["service"]); + + $output = new AjaxResponse(); + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id); + $html = ""; + foreach ($this->getServiceAccounts($this_service) as $key => $service) { + if ($trigger["#value"] && $trigger["#value"] == $key) { + $html .= ''; + } + else { + $html .= ''; + } + } + $output->addCommand(new HtmlCommand($target_preset . ' #edit-svsact select', $html)); + + return $output; + + } + + /** + * Handles AJAX callback for changing the model in the form. + * + * @param array $form The form structure array. + * @param \Drupal\Core\Form\FormStateInterface $form_state The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse The response object containing AJAX commands to update the form. + */ + public function ajaxCallbackChangedModel(array $form, FormStateInterface $form_state): AjaxResponse { + + // Get info from submitted form changes + $trigger = $form_state->getTriggeringElement(); + $active_preset_id = $trigger["#parents"][2]; + $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id]; + // Find the selected service and its prompts + $service_plugins = $this->pluginManagerAiSearch->getDefinitions(); + $this_service_plugin = $service_plugins[$trigger['#value']]; + $this_service = \Drupal::service($this_service_plugin["service"]); + $prompts = $this->getPrompts($this_service); + $this_service_settings = $this_service->getSettings(); + $project_name = $this->getProjects($this_service)[$this_service_settings["project_id"]]; + + $output = new AjaxResponse(); + + // Update the prompts available to this service. If the current + // prompt exists, then use it, otherwise use the "default" + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id); + $output->addCommand(new ReplaceCommand($target_preset . ' #edit-prompt', [ + 'prompt' => [ + '#type' => 'select', + '#options' => $prompts, + '#title' => $this->t("Select the prompt for the AI Model to use:"), + "#default_value" => empty($prompts) ? "default" : ($prompts[$active_preset['prompt']] ?? "default") , + '#description' => $this->t("Prompts are set from the admin page for the model selected."), + '#description_display' => 'after', + '#prefix' => "
", + '#suffix' => "
", + ] + ])); + // Set notification below Service Account + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides-service-account--description b"; + $output->addCommand(new HtmlCommand($target_preset, $this_service_settings["service_account"])); + // Set notification below Project + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides-project-id--description b"; + $output->addCommand(new HtmlCommand($target_preset, $project_name)); + // Set notification below DataStore + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides-datastore-id--description b"; + $output->addCommand(new HtmlCommand($target_preset, $this_service_settings["datastore_id"])); + // Set notification below Engine + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides-engine-id--description b"; + $output->addCommand(new HtmlCommand($target_preset, $this_service_settings["engine_id"])); + + return $output; + + } + + /** + * Handles the AJAX callback to get the list of projects and update the project + * selection options in the form. + * + * Projects are read from Google Cloud + * + * + * @param array $form The form array containing the form elements. + * @param \Drupal\Core\Form\FormStateInterface $form_state The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse The AJAX response with the updated project options. + */ + public function ajaxCallbackGetProjects(array $form, FormStateInterface $form_state): AjaxResponse { + // Get info from submitted form changes + $trigger = $form_state->getTriggeringElement(); + $active_preset_id = $trigger["#parents"][2]; + $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id]; + // Find the selected service and its prompts + $service_plugins = $this->pluginManagerAiSearch->getDefinitions(); + $this_service_plugin = $service_plugins[$active_preset["plugin"]]; + $service = \Drupal::service($this_service_plugin["service"]); + + $output = new AjaxResponse(); + $html = ""; + $p = $form_state->getUserInput()['SearchConfigForm']['presets'][$active_preset_id]['model_tuning']['overrides']['project_id'] ?: NULL; + foreach ($this->getProjects($service) as $key => $project) { + if ($trigger["#value"] && $p == $key) { + $html .= ''; + } + else { + $html .= ''; + } + } + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id); + $output->addCommand(new HtmlCommand($target_preset . ' #edit-project select', $html)); + return $output; + } + + /** + * Handles AJAX callbacks to update datastores in the form. + * + * Datastores are read from Google Cloud + * + * @param array $form The current state of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state The state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse The response object containing set of commands to update the form. + */ + public function ajaxCallbackGetDataStores(array $form, FormStateInterface $form_state): AjaxResponse { + + // Get info from submitted form changes + $trigger = $form_state->getTriggeringElement(); + $active_preset_id = $trigger["#parents"][2]; + $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id]; + $overrides = $active_preset["model_tuning"]["overrides"]; + // Find the selected service and its prompts + $service_plugins = $this->pluginManagerAiSearch->getDefinitions(); + $this_service_plugin = $service_plugins[$active_preset["plugin"]]; + $service = \Drupal::service($this_service_plugin["service"]); + + $output = new AjaxResponse(); + + // Update the datastores available to this project. If the current + // datastore exists, then use it, otherwise use the "default" + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning-overrides"; + $service_account = $overrides["service_account"] == "default" ? "" : $overrides["service_account"]; + $project = $overrides["project_id"] == "default" ? "" : $overrides["project_id"]; + + $html = ""; + $ds = $form_state->getUserInput()['SearchConfigForm']['presets'][$active_preset_id]['model_tuning']['overrides']['datastore_id'] ?: NULL; + $found_datastores = $this->getDatastores($service, $service_account, $project); + $new_datastore = array_key_first($found_datastores); + foreach ($found_datastores as $key => $datastore) { + if ($trigger["#value"] && $ds == $key || count($found_datastores) == 1) { + $html .= ''; + $new_datastore = $key; + } + else { + $html .= ''; + } + } + $output->addCommand(new HtmlCommand($target_preset . ' #edit-datastore select', $html)); + $output->addCommand(new InvokeCommand($target_preset . ' #edit-datastore select', 'attr', ['value', $new_datastore])); + + return $output; + + } + + /** + * Handles AJAX callbacks for retrieving and updating available engines based on form input. + * + * Engines are read from Google Cloud + * + * @param array $form The form structure array. + * @param \Drupal\Core\Form\FormStateInterface $form_state The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse Contains commands to update the frontend with available engines. + */ + public function ajaxCallbackGetEngines(array $form, FormStateInterface $form_state): AjaxResponse { + + // Get info from submitted form changes + $trigger = $form_state->getTriggeringElement(); + $active_preset_id = $trigger["#parents"][2]; + $active_preset = $form_state->getValues()["SearchConfigForm"]["presets"][$active_preset_id]; + $overrides = $active_preset["model_tuning"]["overrides"]; + // Find the selected service and its prompts + $service_plugins = $this->pluginManagerAiSearch->getDefinitions(); + $this_service_plugin = $service_plugins[$active_preset["plugin"]]; + $service = \Drupal::service($this_service_plugin["service"]); + + $output = new AjaxResponse(); + + // Update the datastores available to this project. If the current + // datastore exists, then use it, otherwise use the "default" + $target_preset = '#edit-searchconfigform-presets-' . str_replace("_", "-", $active_preset_id) . "-model-tuning"; + $service_account = $overrides["service_account"] == "default" ? "" : $overrides["service_account"]; + $project = $overrides["project_id"] == "default" ? "" : $overrides["project_id"]; + + $html = ""; + $ds = $form_state->getUserInput()['SearchConfigForm']['presets'][$active_preset_id]['model_tuning']['overrides']['engine_id'] ?: NULL; + + $found_engines = $this->getEngines($service, $service_account, $project); + $new_engine = array_key_first($found_engines); + foreach ($found_engines as $key => $engine) { + if ($trigger["#value"] && $ds == $key) { + $html .= ''; + $new_engine = $key; + } + else { + $html .= ''; + } + } + $output->addCommand(new HtmlCommand($target_preset . ' #edit-engine select', $html)); + $output->addCommand(new InvokeCommand($target_preset . ' #edit-engine select', 'attr', ['value', $new_engine])); + + return $output; + + } + + /** + * Retrieves an array of available prompts from the given service. + * + * @param GcServiceInterface $service The service instance to retrieve prompts from. + * + * @return array An array of available prompts. + */ + private function getPrompts(GcServiceInterface $service) { + return $service->availablePrompts(); + } + + /** + * Retrieves a list of service accounts configured in the GCAPI settings. + * + * @param GcServiceInterface $service The service interface instance used for fetching the settings. + * + * @return array An associative array of service accounts where keys and values represent the account names. + */ + private function getServiceAccounts(GcServiceInterface $service): array { + $settings = CobSettings::getSettings("GCAPI_SETTINGS", "bos_google_cloud"); + $output = ["default" => "use default"]; + foreach ($settings["auth"] as $acct => $setting) { + $output[$acct] = $acct; + } + return $output; + } + + /** + * Retrieves a list of projects from the given service. + * + * @param GcServiceInterface $service + * The service from which to retrieve the projects. + * + * @return array + * An associative array of project identifiers and their corresponding names. + */ + private function getProjects(GcServiceInterface $service, ?string $service_account = NULL): array { + return ["default" => "use default"] + $service->availableProjects($service_account); + } + + /** + * Retrieves an array of available datastores combined with a default option. + * + * @param GcServiceInterface|GcAgentBuilderInterface $service The service instance to retrieve datastores from. + * @param string|null $service_account The service account to use, or NULL to use the default. + * @param string|null $project The project to query datastores for, or NULL to use the default project. + * + * @return array An array of available datastores with a default option included. + */ + private function getDatastores(GcServiceInterface|GcAgentBuilderInterface $service, ?string $service_account = NULL, ?string $project = NULL): array { + return ["default" => "use default"] + $service->availableDatastores($service_account, $project); + } + + /** + * @param \Drupal\bos_google_cloud\Services\GcServiceInterface|\Drupal\bos_google_cloud\Services\GcAgentBuilderInterface $service + * @param string|null $service_account + * @param string|null $project_id + * + * @return string[] + */ + private function getEngines(GcServiceInterface|GcAgentBuilderInterface $service, ?string $service_account = NULL, ?string $project_id = NULL): array { + return ["default" => "use default"] + $service->availableEngines($service_account, $project_id); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchForm.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchForm.php new file mode 100644 index 0000000000..80a3932fe3 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Form/AiSearchForm.php @@ -0,0 +1,297 @@ + ["library" => ["bos_search/core"]], + '#modal_title' => $config["searchform"]["modal_titlebartitle"] ?? "", + ]; + + $preset = $form_state->getBuildInfo()["args"][0]; + if (empty($preset)) { + $preset = AiSearch::getPreset(); + } + $config = $this->config("bos_search.settings")->get("presets.$preset"); + if (empty($config)) { + $form += [ + '#errors' => true, + "problem" => [ + "message" => [ + "#markup" => "

Configuration Error:

The Preset for this form is not correctly setup.
Please set up a configuration at /admin/config/system/boston/aisearch
" + ], + ], + ]; + return $form; + } + + $form += [ + 'AiSearchForm' => [ + '#tree' => FALSE, + + 'content' => [ + '#type' => 'container', + '#attributes' => [ + 'id' => ['edit-aisearchform'], + ], + 'preset' => [ + '#type' => 'hidden', + '#default_value' => $preset, + ], + 'messages' => [ + '#type' => 'container', + '#attributes' => ['id' => ['edit-messages']], + ], + 'searchresults' => [ + '#type' => 'container', + '#attributes' => ['id' => ['edit-searchresults']], + 'welcome' => [ + '#type' => 'container', + '#attributes' => [ + "id" => "edit-welcome", + ], + "title" => [ + '#markup' => Markup::create($config["searchform"]['welcome']["body_title"]) + ], + "body" => [ + '#markup' => Markup::create($config["searchform"]['welcome']["body_text"]) + ], + "cards" => [ + '#type' => 'grid_of_cards', + '#theme' => 'grid_of_cards', + "#title" => "Example", + "#title_attributes" => [], + '#cards' => [ + [ + '#type' => 'card', + '#theme' => 'card', + '#attributes' => [ + 'class' => ['br--4', "bg--lb"] + ], + '#content' => $config["searchform"]['welcome']["cards"]["card_1"] ?: "", + ], + [ + '#type' => 'card', + '#theme' => 'card', + '#attributes' => [ + 'class' => ['br--4', "bg--lb"] + ], + '#content' => $config["searchform"]['welcome']["cards"]["card_2"] ?: "", + ], + [ + '#type' => 'card', + '#theme' => 'card', + '#attributes' => [ + 'class' => ['br--4', "bg--lb"] + ], + '#content' => $config["searchform"]['welcome']["cards"]["card_3"] ?: "", + ], + ] + ], + ], + ], + 'session_id' => [ + '#type' => 'hidden', + '#prefix' => "
", + '#suffix' => "
", + '#default_value' => $form_state->getValue("session_id") ?: "", + ], + ], + 'actions' => [ + '#type' => 'button', + '#value' => 'Search', + "#attributes" => [ + "class" => ["hidden"], + ], + '#ajax' => [ + 'callback' => '::ajaxCallbackSearch', + 'progress' => [ + 'type' => 'none', + ] + ], + ], + 'searchbar' => [ + '#theme' => 'search_bar', + '#default_value' => "", + '#audio_search_input' => $config["searchform"]['searchbar']["audio_search_input"] ?? FALSE, + '#attributes' => [ + "placeholder" => $config["searchform"]['searchbar']["search_text"] ?? "", + ], + "#description" => $config["searchform"]['searchbar']["search_note"] ?? "", + "#description_display" => "after", + ], + ], + ]; + + if (!$config["searchform"]["welcome"]["cards"]["enabled"]) { + unset($form["AiSearchForm"]['content']["searchresults"]["welcome"]["cards"]); + } + + if ($config["results"]["feedback"] ?: 0) { + $form["#attached"]["library"][] = "bos_search/snippet.search_feedback"; + } + + return $form; + + } + + /** + * Ajax callback to run the desired test against the selected AI Model. + * + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + * + * @return array + * @throws \Exception + */ + public function ajaxCallbackSearch(array $form, FormStateInterface $form_state): AjaxResponse { + if ($form_state->getErrors()) { + return new AjaxResponse(); + } + $config = \Drupal::config("bos_search.settings")->get("presets"); + $form_values = $form_state->getUserInput(); + $fake = FALSE; // TRUE = don't actually send to AI Model. + + try { + + // Find the plugin being used (from the preset). + $preset_name = $form_state->getBuildInfo()["args"][0] ?: $config[$form_values["preset"]]; + $preset = $config[$preset_name] ?: FALSE; + if (!$preset) { + throw new \Exception("Cannot find the preset {$preset_name}.}"); + } + if (empty($preset['plugin'])) { + throw new \Exception("The preset {$preset['plugin']} is not defined."); + } + $plugin_id = $preset["plugin"]; + + // Create the search request object. + $request = new AiSearchRequest($form_values["searchbar"], $preset['results']["result_count"] ?? 0); + $request->set("preset", $preset); + + if ($preset["searchform"]["searchbar"]["allow_conversation"] + && !empty($form_values["session_id"])) { + // Set the conversationid. This causes any history for the conversation + // to be reloaded into the $request object. + $request->set("session_id", $form_values["session_id"]); + } + + // Instantiate the plugin, and call the search using the search object. + /** @var \Drupal\bos_search\AiSearchInterface $plugin */ + $plugin = \Drupal::service("plugin.manager.aisearch") + ->createInstance($plugin_id); + + $response = $plugin->search($request, $fake); + + } + catch (\Exception $e) { + $output = new AjaxResponse(); + $output->addCommand(new AppendCommand('#search-conversation-wrapper', [ + "#markup" => " +
+
+
+ There was an error with this query, please try again. +
+ +
+
+" + ])); + $output->addCommand(new ReplaceCommand('#edit-session_id', [ + 'session_id' => [ + '#type' => 'hidden', + '#attributes' => [ + "data-drupal-selector" => "edit-conversation-id", + "name" => "session_id", + ], + '#prefix' => "
", + '#suffix' => "
", + '#value' => $request->get("session_id") ?: "", + ] + ])); + $output->addCommand(new SettingsCommand(["has_results" => TRUE], TRUE)); + + return $output; + } + + // Save this search so we can continue the conversation later + if ($preset["searchform"]["searchbar"]["allow_conversation"] && $request->get("session_id") != $response->getAll()["session_id"]) { + // Either the session_id was not yet created, or else the session + // for the original conversation has timed-out. + // Load the session_id into the request. + $request->set("session_id", $response->getAll()["session_id"]); + } + $request->addHistory($response); + $request->save(); + + // This will render the output form using the input array. + $rendered_result = [ + "#type" => "inline_template", + "#template" => $response->build() + ]; + $output = new AjaxResponse(); + $output->addCommand(new AppendCommand('#search-conversation-wrapper', $rendered_result)); + $output->addCommand(new ReplaceCommand('#edit-session_id', [ + 'session_id' => [ + '#type' => 'hidden', + '#attributes' => [ + "data-drupal-selector" => "edit-conversation-id", + "name" => "session_id", + ], + '#prefix' => "
", + '#suffix' => "
", + '#value' => $request->get("session_id") ?: "", + ] + ])); + $output->addCommand(new SettingsCommand(["has_results" => TRUE], TRUE)); + + return $output; + + } + + /** + * @inheritDoc + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // Not required for this form. + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitation.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitation.php new file mode 100644 index 0000000000..e86111b89a --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitation.php @@ -0,0 +1,66 @@ +startIndex = $startIndex; + $this->endIndex = $endIndex; + $this->sources = $sources; + } + + /** + * Returns an array with all the properties of this class. + * + * @return array + */ + public function getCitation(): array { + return [ + "startIndex" => $this->startIndex, + "endIndex" => $this->endIndex, + "sources" => $this->sources, + ]; + } + + /** + * Adds a new source (reference) to this citation. + * + * @param int $referenceIndex + * + * @return \use Drupal\bos_search\Model\AiSearchCitation Returns the instance of the AIsearchReference for method chaining. + */ + public function addSource(array $source, int $key): AiSearchCitation { + if (empty($key)) { + $key = count($this->sources ?? []); + } + $this->sources[$key] = [ + "referenceIndex" => $source["referenceIndex"], + "relevanceScore" => $source["relevanceScore"], + ]; + return $this; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitationCollection.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitationCollection.php new file mode 100644 index 0000000000..d78ddf2b85 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchCitationCollection.php @@ -0,0 +1,68 @@ +citations ?? []); + } + // Check for duplicates. + $cit = $citation->getCitation(); + if (isset($this->citations)) { + foreach ($this->citations as &$existing_citation) { + if ($existing_citation['startIndex'] == $cit['startIndex']) { + $existing_citation["sources"] = array_merge($existing_citation['sources'], $cit['sources']); + return $this; + } + } + } + + $this->citations[$key] = $cit; + return $this; + } + + public function updateCitation($key, array $citation):AiSearchCitationCollection { + $this->citations[$key] = $citation; + return $this; + } + /** + * Get all results as an array of AISearchCitation objects. + * + * @return array + */ + public function getCitations(): array { + return $this->citations; + } + + /** + * Returns the number of AISearchCitation objects in the collection. + * @return int + */ + public function count(): int { + return count($this->citations); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsBase.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsBase.php new file mode 100644 index 0000000000..1bbad025f1 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsBase.php @@ -0,0 +1,79 @@ +{$key} = array_merge($this->{$key} ?? [], $value); + } + else { + $this->{$key} = $value; + } + return $this; + } + + /** + * @inheritDoc + */ + public function get(string $key): mixed { + return $this->{$key} ?? NULL; + } + + /** + * @inheritDoc + */ + public function toArray(): array { + return $this->trimArray($this->object) ?: []; + } + + /** + * @inheritDoc + */ + public function toJson(): string { + return json_encode($this->toArray()); + } + + /** + * Removes elements in the array which been set to null. + * + * @param $array + * + * @return NULL|array + */ + private function trimArray($array): NULL|array { + $output = []; + foreach ($array as $key => $value) { + if ($value != NULL) { + if (is_object($value)) { + $newval = $this->trimArray($value->toArray()); + if ($newval !== NULL) { + $output[$key] = $newval; + } + } + else { + $output[$key] = $value; + } + } + } + return empty($output) ? NULL : $output; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsInterface.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsInterface.php new file mode 100644 index 0000000000..a4fcc81e14 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchObjectsInterface.php @@ -0,0 +1,38 @@ +title = $title; + $this->uri = $uri; + $this->ref = $ref; + } + + /** + * Returns an array with all the properties of this class. + * + * @return array + */ + public function getReference(): array { + return [ + "title" => $this->title, + "uri" => $this->uri, + "ref" => $this->ref, + "chunkContents" => $this->chunkContents, + "seq" => $this->seq, + "original_seq" => $this->original_seq, + "locations" => $this->locations, + "is_result" => $this->is_result, + ]; + } + + /** + * Adds content to a specific page chunk. + * + * @param string $content The content to be added. + * @param string $pageIdentifier The identifier of the page where the content will be added. + * + * @return AIsearchReference Returns the instance of the AIsearchReference for method chaining. + */ + public function addChunkContent(string $content, string $pageIdentifier): AiSearchReference { + $this->chunkContents[] = [ + "content" => $content, + "pageIdentifier" => $pageIdentifier, + ]; + return $this; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchReferenceCollection.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchReferenceCollection.php new file mode 100644 index 0000000000..acbbd271db --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchReferenceCollection.php @@ -0,0 +1,53 @@ +references ?? []); + } + $this->references[$key] = $reference->getReference(); + return $this; + } + + /** + * Get all results as an array of AISearchReference objects. + * + * @return array + */ + public function getReferences(): array { + return $this->references; + } + + /** + * Returns the number of AISearchReference objects in the collection. + * @return int + */ + public function count(): int { + return count($this->references); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchRequest.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchRequest.php new file mode 100644 index 0000000000..eebcdb5581 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchRequest.php @@ -0,0 +1,109 @@ +search_text = trim($search_text); + } + $this->result_count = $result_count; + if (!empty($result_template)) { + $this->result_template = $result_template; + } + } + + public function addHistory(AiSearchResponse $search): AiSearchRequest { + $this->history[] = $search; + return $this; + } + public function getHistory(): array { + return $this->history; + } + + public function set(string $key, mixed $value): AiSearchRequest { + if ($key == "session_id") { + $this->load($value); + } + else { + parent::set($key, $value); + } + return $this; + } + + public function getId(): string { + return $this->session_id; + } + + /** + * Save the conversation history so it can be retrieved later. + * + * @return void + */ + public function save(): void { + Drupal::service("keyvalue.expirable") + ->get("bos_aisearch") + ->setWithExpire($this->session_id, $this->getHistory(), 300); + } + + /** + * Loads a previous conversation history from key:value store. + * + * @param string $id Conversation ID to retrieve + * + * @return \Drupal\bos_search\AiSearchRequest + */ + protected function load(string $id = ""): AiSearchRequest { + if (!empty($id)) { + // use the supplied session_id rather than the one set in the class. + $this->session_id = $id; + } + + if (!empty($this->session_id)) { + // If we have a saved conversation, load it up. + if ($history = Drupal::service("keyvalue.expirable") + ->get("bos_aisearch") + ->get($this->session_id) ?? FALSE) { + $this->history = $history; + } + + } + return $this; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResponse.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResponse.php new file mode 100644 index 0000000000..bbab673787 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResponse.php @@ -0,0 +1,215 @@ +summary = $summary; + $this->session_id = $session_id; + $this->search = $search; + $this->citations = new AiSearchCitationCollection(); + $this->references = new AiSearchReferenceCollection(); + $this->search_results = new AiSearchResultCollection(); + $this->search_results->setMaxResults($search->get("result_count")); + } + + /** + * + * + * @param \Drupal\bos_search\AiSearchResult $result + * + * @return $this + */ + public function addResult(AiSearchResult $result): AiSearchResponse { + $this->search_results->addResult($result); + return $this; + } + + public function addCitation(AiSearchCitation $citation, int $key = NULL): AiSearchResponse { + $this->citations->addCitation($citation, $key); + return $this; + } + + public function updateCitation(AiSearchCitation $citation, int $key = NULL): AiSearchCitation { + $this->citations->updateCitation($citation, $key); + return $this; + } + + public function addReference(AiSearchReference $reference, $key = NULL): AiSearchResponse { + $this->references->addReference($reference, $key); + return $this; + } + + public function setReferenceId(int $oldReferenceId, int $newReferenceId): void { + foreach ($this->citations as $citation) { + + } + } + + public function getAll(): array { + return [ + "ai_answer" => $this->summary, + "no_results" => $this->no_results, + "violations" => $this->violations, + "session_id" => $this->session_id, + "results" => $this->search_results->getResults() + ]; + } + + public function getMetaData():array { + return $this->metadata; + } + + public function getResults():array { + return $this->search_results->getResults(); + } + + public function getResultsCollection():AiSearchResultCollection { + return $this->search_results; + } + + public function getCitationsCollection():AiSearchCitationCollection { + return $this->citations; + } + + public function getReferences():array { + return $this->references->getReferences(); + } + + public function getCitations():array { + return $this->citations->getCitations(); + } + + public function build(): string { + + $preset = $this->search->get("preset") ?? []; + + $render_array = ['#theme' => 'results__' . $preset["searchform"]["theme"]]; + + $response = $this->getAll(); + + if ($response["no_results"] == 0 && empty($response["violations"]) && $this->search_results) { + // A summary and optionally citations and results have been returned + // from the AI Model. + $render_array += [ + '#id' => $this->search->getId(), + '#response' => $this->summary, + '#feedback' => [ + "#theme" => "aisearch_feedback", + "#thumbsup" => TRUE, + "#thumbsdown" => TRUE, + ], + '#metadata' => $preset["results"]["metadata"] ? ($this->metadata ?? NULL) : NULL, + ]; + + // Add in the search Result items. + foreach ($this->search_results->getResults() as $result) { + $render_array["#items"][] = $result->getResult(); + } + + // Add in the Citation References. + if ($preset["results"]["citations"]) { + foreach ($this->references->getReferences() as $citation) { + $render_array['#citations'][] = $citation; + } + } + + if (!$preset["results"]["summary"] ?? TRUE) { + // If we are supressing the summary, then also supress the citations. + $render_array["#content"] = NULL; + $render_array["#citations"] = NULL; + } + + if (!$preset["results"]["feedback"] ?? TRUE) { + // If we are supressing feedback. + $render_array["#feedback"] = NULL; + } + } + elseif (!empty($response["violations"])) { + // There were violations. + $render_array += [ + '#items' => $this->search_results->getResults() ?? [], + '#content' => $this->search->get("search_text"), + '#id' => $this->search->getId(), + '#response' => $preset["results"]["violations_text"] ?? "Non-conforming Query", + '#metadata' => $preset["results"]["metadata"] ? ($this->metadata ?? NULL) : NULL, + ]; + + if (!$preset["results"]["summary"] ?? TRUE) { + // If we are supressing the summary, then also supress the citations. + $render_array["#content"] = NULL; + $render_array["#citations"] = NULL; + } + + if (!$preset["results"]["feedback"] ?? TRUE) { + // If we are supressing feedback. + $render_array["#feedback"] = NULL; + } + } + else { + // No results message was returned from the AI. + $render_array += [ + '#id' => $this->search->getId(), + '#feedback' => [ + "#theme" => "aisearch_feedback", + "#thumbsup" => TRUE, + "#thumbsdown" => TRUE, + ], + '#response' => $preset["results"]["no_result_text"], + '#no_results' => $this->no_results, + '#metadata' => $preset["results"]["metadata"] ? ($this->metadata ?? NULL) : NULL, + ]; + // Add in the search Result items. + foreach ($this->search_results->getResults() as $result) { + $render_array["#items"][] = $result->getResult(); + } + + } + // Allow to override the theme template. + if (!empty($this->search->get("result_template"))) { + $render_array['#theme'] = $this->search->get("result_template"); + } + return \Drupal::service("renderer")->render($render_array); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResult.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResult.php new file mode 100644 index 0000000000..0bf7016572 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResult.php @@ -0,0 +1,72 @@ +title = $title; + $this->link = $link; + $this->summary = $summary; + } + + /** + * Returns an array with all the properties of this class. + * + * @return array + */ + public function getResult(): array { + return [ + "content" => $this->content, + "description" => $this->description, + "id" => $this->id, + "link" => $this->link, + "link_title" => $this->link_title, + "ref" => $this->ref, + "summary" => $this->summary, + "title" => $this->title, + "nid" => $this->nid + ]; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResultCollection.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResultCollection.php new file mode 100644 index 0000000000..61989abdd5 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Model/AiSearchResultCollection.php @@ -0,0 +1,77 @@ +max_count = $max_count; + } + $this->results = []; + } + + /** + * Add a search result to the collection. + * + * @param \Drupal\bos_search\AiSearchResult $result + * + * @return $this + */ + public function addResult(AiSearchResult $result): AiSearchResultCollection { + if ($this->max_count === 0 || $this->count() < $this->max_count) { + // Only add the requested number of results. + $this->results[] = $result; + } + return $this; + } + + public function updateResult($key, AiSearchResult $result):AiSearchResultCollection { + $this->results[$key] = $result; + return $this; + } + + /** + * Get all results as an array of AiSearchResult objects. + * + * @return array + */ + public function getResults(): array { + return $this->results; + } + + /** + * Returns the number of AiSearchResult objects in the collection. + * @return int + */ + public function count(): int { + return count($this->results); + } + + /** + * Sets the maximum number of AiSearchResults objects allowed in the collection. + * @param $count + * + * @return void + */ + public function setMaxResults(int $count):void { + $this->max_count = $count; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/AiSearch/AiSearchPluginManager.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/AiSearch/AiSearchPluginManager.php new file mode 100644 index 0000000000..9eb0153ad1 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/AiSearch/AiSearchPluginManager.php @@ -0,0 +1,28 @@ +alterInfo('aisearch_info'); + $this->setCacheBackend($cache_backend, 'aisearch_plugins'); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchButtonBlock.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchButtonBlock.php new file mode 100644 index 0000000000..f1aa78ffde --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchButtonBlock.php @@ -0,0 +1,157 @@ + "Search", + "search_button_css" => "", + "aisearch_config_preset" => "" + ]; + } + + /** + * {@inheritdoc} + */ + public function blockForm($form, FormStateInterface $form_state): array { + $presets = AiSearch::getPresets(); + $form = parent::blockForm($form, $form_state); + $form['button'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Search Button Display'), + '#description' => $this->t('Settings for the button used to launch the search form.'), + '#description_display' => 'before', + 'search_button_title' => [ + '#type' => 'textfield', + '#title' => $this->t('Button Text'), + '#description' => $this->t('Enter the text to appear on the search button.'), + '#default_value' => $this->configuration['search_button_title'] ?? "", + ], + 'search_button_css' => [ + '#type' => 'textfield', + '#title' => $this->t('Search Button Custom css'), + '#description' => $this->t('Add any additional css classes to the button'), + '#default_value' => $this->configuration['search_button_css'] ?? "", + ], + 'search_block_text' => [ + '#type' => 'textarea', + '#title' => $this->t('Search Block Body Text'), + '#description' => $this->t('Enter the body text to appear alongside the search button. Can be left blank.'), + '#default_value' => $this->configuration['search_block_text'] ?? "", + ], + ]; + $form['display'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Search Form Display'), + '#description' => $this->t('Settings which control the way the search form is presented to the user.'), + '#description_display' => 'before', + 'aisearch_config_display' => [ + '#type' => 'radios', + '#title' => $this->t('Form Type'), + '#options' => [ + 0 => 'Modal (form will show in a popup window)', + 1 => 'Block (form will display in a block on a page)', + ], + '#description' => $this->t('Select the display method for the search form.'), + '#default_value' => $this->configuration['aisearch_config_display'] ?? "", + ], + 'aisearch_config_preset' => [ + '#type' => 'select', + '#title' => $this->t('AI-Enabled Search Preset'), + '#options' => $presets, + '#description' => $this->t('Select the AI Model (and settings) for the Modal Search Form.'), + '#default_value' => $this->configuration['aisearch_config_preset'] ?? "", + '#states' => [ + 'visible' => [ + ':input[name="settings[display][aisearch_config_display]"]' => ['value' => '0'], + ], + ], + ], + 'aisearch_config_searchpage' => [ + '#type' => 'textfield', + '#title' => $this->t('Host Form Page'), + '#autocomplete_route_name' => 'bos_search.autocomplete_nodes', + '#description' => $this->t('Please select the page which contains the search block.'), + '#default_value' => $this->configuration['aisearch_config_searchpage'] ?? "", + '#states' => [ + 'visible' => [ + ':input[name="settings[display][aisearch_config_display]"]' => ['value' => '1'], + ], + ], + ], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function blockSubmit($form, FormStateInterface $form_state): void { + parent::blockSubmit($form, $form_state); + $this->configuration['aisearch_config_preset'] = $form_state->getValue('display')['aisearch_config_preset']; + $this->configuration['aisearch_config_display'] = $form_state->getValue('display')['aisearch_config_display']; + $this->configuration['aisearch_config_searchpage'] = $form_state->getValue('display')['aisearch_config_searchpage']; + $this->configuration['search_button_title'] = $form_state->getValue('button')['search_button_title']; + $this->configuration['search_block_text'] = $form_state->getValue('button')['search_block_text']; + $this->configuration['search_button_css'] = $form_state->getValue('button')['search_button_css']; + } + + /** + * {@inheritdoc} + */ + public function build(): array { + + if ($this->configuration["aisearch_config_display"] === "0") { + $url = Url::fromRoute('bos_search.open_AISearchForm'); + } + else { + $url = $this->configuration["aisearch_config_searchpage"]; + } + + $config = AiSearch::getPresetValues($this->configuration["aisearch_config_preset"]); + $custom_theme_path = "/modules/custom/bos_components/modules/bos_search/templates/presets/{$config['searchform']['theme']}"; + + return [ + '#theme' => 'aisearch_button', + '#attached' => [ + "library" => ["bos_search/dynamic-loader"], + "drupalSettings" => [ + "bos_search" => [ + 'dynamic_script' => "$custom_theme_path/js/preset.js", + 'dynamic_style' => "$custom_theme_path/css/preset.css", + ] + ], + ], + '#search_form_url' => $url, + '#button_title' => $this->configuration["search_button_title"], + '#button_css' => $this->configuration["search_button_css"], + '#preset' => $this->configuration["aisearch_config_preset"], + '#body' => $this->configuration["search_block_text"], + '#display' => $this->configuration["aisearch_config_display"] == "0" ? "modal" : "block", + ]; + + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchFormBlock.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchFormBlock.php new file mode 100644 index 0000000000..a2c2cb3ed2 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Plugin/Block/AiSearchFormBlock.php @@ -0,0 +1,78 @@ + "AI Search of Site", + "aisearch_config_preset" => "" + ]; + } + + /** + * {@inheritdoc} + */ + public function blockForm($form, FormStateInterface $form_state) { + $presets = AiSearch::getPresets(); + $form = parent::blockForm($form, $form_state); + $form['preset'] = [ + '#type' => 'fieldset', + '#title' => 'Search Block Preset', + '#description' => $this->t('Please provide a preset to be used by this form block.'), + '#description_display' => 'before', + 'aisearch_config_preset' => [ + '#type' => 'select', + '#options' => $presets, + '#description' => $this->t('This defines the AI Model (and settings) that the Search Form will utilise.

Presets are defined at admin/config/system/boston/aisearch'), + '#default_value' => $this->configuration['aisearch_config_preset'] ?? "", + ], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function blockSubmit($form, FormStateInterface $form_state) { + parent::blockSubmit($form, $form_state); + $this->configuration['aisearch_config_preset'] = $form_state->getValue('preset')['aisearch_config_preset']; + } + + /** + * {@inheritdoc} + */ + public function build() { + $preset = \Drupal::request()->query->get('preset') ?: ($this->configuration["aisearch_config_preset"] ?: AiSearch::getPreset()); + $params = [ + "preset" => $preset, + ]; + return [ + [ + '#lazy_builder' => ['bos_search.callbacks:renderSearchForm', $params ], + '#create_placeholder' => TRUE, + ], + ]; + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/src/Twig/CustomFiltersExtension.php b/docroot/modules/custom/bos_components/modules/bos_search/src/Twig/CustomFiltersExtension.php new file mode 100644 index 0000000000..1e9ae45765 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/src/Twig/CustomFiltersExtension.php @@ -0,0 +1,38 @@ + 0); + } + +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/components/card.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/card.html.twig new file mode 100644 index 0000000000..05f63f2ca1 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/card.html.twig @@ -0,0 +1,73 @@ +{# +/** + * @file components/card.html.twig + * Theme for a grid of cards + * @see https://patterns.boston.gov/components/detail/quote-card--default.html + * + * Variables which can be used are: + * attributes - an array of attributes for the outside wrapper around the + * card component. + * quote_text - The actual quote body. + * picture - A URL for an image to be shown above the persons name. + * show_quotes - boolean flag for whether quotes image should be shown. + * Note: defaults to FALSE if picture has a value. + * link - A URL link which apoears above the persons name. + * link_text - The text for the link. + * location - A location printed below the persons name. +*/ +#} + +{% + set classes = [ + "cd", + "m-t500", + "g--4", + "g--4--sl", + "card", + "card-wrapper" + ] +%} +{% + set title_classes = [ + "cd-t", + "card-title" + ] +%} +{% + set subtitle_classes = [ + "t--upper", + "t--subtitle", + "cd-st", + "card-subtitle" + ] +%} +{% + set content_classes = [ + "cd-d", + "card-content" +] +%} + +{{ attach_library("bos_search/component.card") }} + + + + {% if image %} +
+ {% endif %} + +
+ + {% if title %} + {{ title }}
+ {% endif %} + + {% if subtitle %} + {{ subtitle }}
+ {% endif %} + + {{ content }}
+ + + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/components/grid-of-cards.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/grid-of-cards.html.twig new file mode 100644 index 0000000000..252e813c8b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/grid-of-cards.html.twig @@ -0,0 +1,43 @@ +{# + /** + * @file components/grid-of-cards.html.twig + * Theme for a grid of cards + * @see https://patterns.boston.gov/components/detail/card--grid.html + * + * Variables which can be used are: + * attributes - an array of attributes for the outside wrapper around the + * grid component. + * title - a title for the grid. + * attributes - an array of attributes for the title. + * cards - an array of cards. + */ +#} +{% + set classes = [ + "b--w", + "b--fw", + "grid-of-cards" + ] +%} +{% + set title_classes = [ + "txt-l", + "goc-title" + ] +%} + +{{ attach_library("bos_search/component.grid_of_cards") }} + +
+
+ + {% if title %} +
{{ title }}
+ {% endif %} + +
+ {{ cards }} +
+ +
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/components/quote-card.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/quote-card.html.twig new file mode 100644 index 0000000000..4a52ad107b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/quote-card.html.twig @@ -0,0 +1,64 @@ +{# +/** + * @file components/quote-card.html.twig + * Theme for a grid of cards + * @see https://patterns.boston.gov/components/detail/quote-card--default.html + * + * Variables which can be used are: + * attributes - an array of attributes for the outside wrapper around the + * card component. + * quote_text - The actual quote body. + * picture - A URL for an image to be shown above the persons name. + * show_quotes - boolean flag for whether quotes image should be shown. + * Note: defaults to FALSE if picture has a value. + * link - A URL link which apoears above the persons name. + * link_text - The text for the link. + * location - A location printed below the persons name. +*/ +#} + +{% + set classes = [ + "goq", + "g--3", + "g--3--sl", + "m-t500", + "m-b300" + ] +%} + +{{ attach_library("bos_search/component.quote_card") }} + +
+ +
"{{ content }}"
+ +
+ + {% if picture or show_quotes %} +
+ {% if not picture and show_quotes %} + No picture available + {% endif %} + {% if picture %} + No picture available + {% endif %} + {% if link %} + {{ link_text }} + {% endif %} +
+ {% endif %} + + {% if person or location %} +
+ {% if person %} +
{{ person }}
+ {% endif %} + {% if location %} +
{{ location }}
+ {% endif %} +
+ {% endif %} + +
+
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/components/search-bar.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/search-bar.html.twig new file mode 100644 index 0000000000..dedcf7e099 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/components/search-bar.html.twig @@ -0,0 +1,68 @@ +{# +/** + * @file + * Theme override for an 'input' #type form element. + * + * Available variables: + * - attributes: A list of HTML attributes for the input element. + * - children: Optional additional rendered elements. + * + * @see template_preprocess_input() + */ +#} +{% + set wrapper_classes = [ + "search-bar-wrapper" + ] +%} +{% + set classes = [ + "search-bar" + ] +%} +{% + set title_classes = [ + "search-bar-title", + "txt-l" + ] +%} + +{{ attach_library("bos_search/component.search_bar") }} + + + + {% if title %} + + {% if title_prefix %} + {{ title_prefix }} + {% endif %} + + {{ title }} + + {% if title_suffix %} + {{ title_suffix }} + {% endif %} + + {% endif %} + +
+ + {% if description and description_display == "before" %} +
{{ description }}
+ {% endif %} + +
+ {{ value }} + {% if audio_search_input %} +
+ {% endif %} +
+
+ + {% if description and description_display == "after" %} +
{{ description }}
+ {% endif %} + +
+ + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/form-element--webform-checkbox.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/form-element--webform-checkbox.html.twig new file mode 100644 index 0000000000..a13ff8ca43 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/form-element--webform-checkbox.html.twig @@ -0,0 +1,95 @@ +{# +/** + * @file + * Theme override for a form element. + * + * Available variables: + * - attributes: HTML attributes for the containing element. + * - errors: (optional) Any errors for this form element, may not be set. + * - prefix: (optional) The form element prefix, may not be set. + * - suffix: (optional) The form element suffix, may not be set. + * - required: The required marker, or empty if the associated form element is + * not required. + * - type: The type of the element. + * - name: The name of the element. + * - label: A rendered label element. + * - label_display: Label display setting. It can have these values: + * - before: The label is output before the element. This is the default. + * The label includes the #title and the required marker, if #required. + * - after: The label is output after the element. For example, this is used + * for radio and checkbox #type elements. If the #title is empty but the + * field is #required, the label will contain only the required marker. + * - invisible: Labels are critical for screen readers to enable them to + * properly navigate through forms but can be visually distracting. This + * property hides the label for everyone except screen readers. + * - attribute: Set the title attribute on the element to create a tooltip but + * output no label element. This is supported only for checkboxes and radios + * in \Drupal\Core\Render\Element\CompositeFormElementTrait::preRenderCompositeFormElement(). + * It is used where a visual label is not needed, such as a table of + * checkboxes where the row and column provide the context. The tooltip will + * include the title and required marker. + * - description: (optional) A list of description properties containing: + * - content: A description of the form element, may not be set. + * - attributes: (optional) A list of HTML attributes to apply to the + * description content wrapper. Will only be set when description is set. + * - description_display: Description display setting. It can have these values: + * - before: The description is output before the element. + * - after: The description is output after the element. This is the default + * value. + * - invisible: The description is output after the element, hidden visually + * but available to screen readers. + * - disabled: True if the element is disabled. + * - title_display: Title display setting. + * + * @see template_preprocess_form_element() + */ +#} +{% + set classes = [ + 'js-form-item', + 'form-item', + 'form-type-' ~ type|clean_class, + 'js-form-type-' ~ type|clean_class, + 'form-item-' ~ name|clean_class, + 'js-form-item-' ~ name|clean_class, + title_display not in ['after', 'before'] ? 'form-no-label', + disabled == 'disabled' ? 'form-disabled', + errors ? 'form-item--error', + ] +%} +{% + set description_classes = [ + 'description', + description_display == 'invisible' ? 'visually-hidden', + ] +%} + + {% if label_display in ['before', 'invisible'] %} + {{ label }} + {% endif %} + {% if prefix is not empty %} + {{ prefix }} + {% endif %} + {% if description_display == 'before' and description.content %} + + {{ description.content }} + + {% endif %} + {{ children }} + {% if suffix is not empty %} + {{ suffix }} + {% endif %} + {% if label_display == 'after' %} + {{ label }} + {% endif %} + {% if errors %} +
+ {{ errors }} +
+ {% endif %} + {% if description_display in ['after', 'invisible'] and description.content %} + + {{ description.content }} + + {% endif %} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--button.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--button.html.twig new file mode 100644 index 0000000000..02a3a3c319 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--button.html.twig @@ -0,0 +1,40 @@ +{# +/** + * @file + * Theme for the AI Search Button Block. + * + * Available variables: + * - plugin_id: The ID of the block implementation. + * - label: The configured label of the block if visible. + * - configuration: A list of the block's configuration values. + * - label: The configured label for the block. + * - label_display: The display settings for the label. + * - provider: The module or other provider that provided this block plugin. + * - Block plugin specific settings will also be stored here. + * - content: The content of this block. + * - attributes: array of HTML attributes populated by modules, intended to + * be added to the main container tag of this template. + * - id: A valid HTML ID and guaranteed unique. + * - title_attributes: Same as attributes, except applied to the main title + * tag that appears in the template. + * - title_prefix: Additional output populated by modules, intended to be + * displayed in front of the main title tag that appears in the template. + * - title_suffix: Additional output populated by modules, intended to be + * displayed after the main title tag that appears in the template. + * + * @see template_preprocess_block() + */ +#} +{% set classes = ["block", "aienabledsearchbutton-inner-wrapper", "b-c"] %} +
+ + {{ title_prefix }} + {% if label %} + {{ label }}. + {% endif %} + {{ title_suffix }} + {% block content %} + {{ content }} {# @see aisearch-button.html.twig #} + {% endblock %} + +
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--form.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--form.html.twig new file mode 100644 index 0000000000..295be5ae58 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/block--form.html.twig @@ -0,0 +1,38 @@ +{# +/** + * @file + * Theme for the AI Search Form Block. + * + * Available variables: + * - plugin_id: The ID of the block implementation. + * - label: The configured label of the block if visible. + * - configuration: A list of the block's configuration values. + * - label: The configured label for the block. + * - label_display: The display settings for the label. + * - provider: The module or other provider that provided this block plugin. + * - Block plugin specific settings will also be stored here. + * - content: The content of this block. + * - attributes: array of HTML attributes populated by modules, intended to + * be added to the main container tag of this template. + * - id: A valid HTML ID and guaranteed unique. + * - title_attributes: Same as attributes, except applied to the main title + * tag that appears in the template. + * - title_prefix: Additional output populated by modules, intended to be + * displayed in front of the main title tag that appears in the template. + * - title_suffix: Additional output populated by modules, intended to be + * displayed after the main title tag that appears in the template. + * + * @see template_preprocess_block() + */ +#} +{% set classes = ['block','block-aisearchform','aienabledsearchform'] %} + + {{ title_prefix }} + {% if label %} + {{ label }} + {% endif %} + {{ title_suffix }} + {% block content %} + {{ content }} + {% endblock %} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/checkboxes.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/checkboxes.html.twig new file mode 100644 index 0000000000..196dc6f96e --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/checkboxes.html.twig @@ -0,0 +1,13 @@ +{# +/** + * @file + * Theme override for a 'checkboxes' #type form element. + * + * Available variables + * - attributes: A list of HTML attributes for the wrapper element. + * - children: The rendered checkboxes. + * + * @see template_preprocess_checkboxes() + */ +#} +{{ children }} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/container.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/container.html.twig new file mode 100644 index 0000000000..0da6c388d0 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/container.html.twig @@ -0,0 +1,28 @@ +{# +/** + * @file + * Theme override of a container used to wrap child elements. + * + * Used for grouped form items. Can also be used as a theme wrapper for any + * renderable element, to surround it with a
and HTML attributes. + * See \Drupal\Core\Render\Element\RenderElement for more + * information on the #theme_wrappers render array property, and + * \Drupal\Core\Render\Element\container for usage of the container render + * element. + * + * Available variables: + * - attributes: HTML attributes for the containing element. + * - children: The rendered child elements of the container. + * - has_parent: A flag to indicate that the container has one or more parent + containers. + * + * @see template_preprocess_container() + */ +#} +{% + set classes = [ + has_parent ? 'js-form-wrapper', + has_parent ? 'form-wrapper', + ] +%} +{{ children }}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/css/preset.css b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/css/preset.css new file mode 100644 index 0000000000..bca7190fac --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/css/preset.css @@ -0,0 +1,164 @@ +/* Locks the Searchbar to the bottom of the wrapper */ +.aienabledsearchform.aisearch-modal-form div.search-bar-wrapper, +.aienabledsearchform.block-aisearchform div.search-bar-wrapper { + position: sticky; + bottom: 0; + background-color: white; + display: flex; + padding-top: 4px; +} + +.bos-search-aisearchform .search-request { + border: none; + text-align: left; +} + + /* Vertically locates the welcome area in the center of the window */ +.aienabledsearchform #bos-search-aisearchform #edit-searchresults #edit-welcome { +} +.aienabledsearchform .search-citations-wrapper { + margin-bottom: 24px; +} +.aienabledsearchform .search-citations-wrapper.hide-citation { + margin-bottom: 0; +} +.aienabledsearchform .search-citations-drawer .search-citation-more { + display:none; +} +.aienabledsearchform .search-citations-drawer { + margin: 0; + min-height: 44px; +} + +.aienabledsearchform .search-citations-drawer label { + padding: 8px 53px 8px 16px; + width: 100%; + font-size: 1em; + line-height: 28px; + background-color: #f2f2f2; + cursor: pointer; +} +.aienabledsearchform .search-citations-drawer label:hover { + background-color: #288be4; + color: #fff; +} +.aienabledsearchform .search-citations-drawer label:active { + background-color: #091f2f; + color: #fff; +} +.aienabledsearchform .search-citations-drawer .dr-ic { + right: 16px; + top: 22px; + height: 20px; + margin: 0; + width: 20px; + padding-top: 0px; + padding-right: 0px; + /* width: 24px; */ +} +.aienabledsearchform .search-citations-drawer .dr-tr:checked~.dr-h .dr-ic { + margin-top: 0; +} +.aienabledsearchform .search-citations-drawer .dr-ic svg { +} +.aienabledsearchform .search-citations-drawer .dr-t { + line-height: 48px; +} +.block .aienabledsearchbutton { + display: flex; + flex-flow: column-reverse; +} +.block .aienabledsearchbutton .button { +} +.block .aienabledsearchbutton legend { +} +.block-aisearchform .modal-close-wrapper { + position: fixed; + width:100%; + +} +.block-aisearchform .ai-form-reset { +} +.block-aisearchform .ai-reset-button:after { + content: "New Search"; +} +.block-aisearchform .modal-close { + display: none; +} +.goc-grid .card-wrapper div.card-content-wrapper { + text-align: left; +} +.bos-search-aisearchform .search-request-wrapper:first-of-type { + margin-top: 100px; +} +.bos-search-aisearchform .search-metadata-set-wrapper table { + border-collapse: initial; + /* border-spacing: 8px; */ +} +.bos-search-aisearchform .search-metadata-title { + vertical-align: middle; + width: 33%; + max-width: 40%; + font-weight: bold; + overflow-wrap: anywhere; + text-wrap: balance; + padding: 8px 8px; +} +.bos-search-aisearchform .search-metadata-value { + vertical-align: middle; + overflow-wrap: anywhere; + padding: 8px; +} +.bos-search-aisearchform .search-metadata-wrapper { + background-color: #f3f3f3; +} +.bos-search-aisearchform .search-metadata-set-wrapper tbody tr:nth-child(even) td { + background-color: #f3f3f3; +} +/** + * DESKTOP adjustments + * This component considers mobile upto window width of 480px + */ + +@media screen and (min-width: 480px) { + .aienabledsearchform.aisearch-modal-form div.search-bar-wrapper, + .aienabledsearchform.block-aisearchform div.search-bar-wrapper { + bottom: 0; + margin-left: -12px; + width: calc(100% + 12px); + } + + .aienabledsearchform .search-bar-input-wrapper { + margin: 5px auto 0 12px; + } + + .aienabledsearchform .search-citations-drawer.show-more .search-citation-more-wrapper { + background-color: #f9f9f9; + padding: 16px; + } + .aienabledsearchform #bos-search-aisearchform #edit-searchresults #edit-welcome {} + .aienabledsearchform .search-citations-drawer.show-more .search-citation-more { + display:block; + color: #1871bd; + background-color: #f2f2f2; + text-decoration: none; + font-size: calc(1em + 2px); + font-family: Montserrat, Arial, sans-serif; + margin-top: -16px; + border-radius: 2px; + } + .aienabledsearchform .search-citations-drawer.show-more .search-citation-more:hover, + .aienabledsearchform .search-citations-drawer.show-more .search-citation-more:focus, + .aienabledsearchform .search-citations-drawer.show-more .search-citation-more:active { + background-color: rgba(40, 139, 228, 0.10); + } + .block-aisearchform .modal-close-wrapper { + position: fixed; + width: 100%; + z-index: 10; + height: 80px; + } + .adminimal-admin-toolbar .block-aisearchform .modal-close-wrapper { + top: 120px; + } +} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/details.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/details.html.twig new file mode 100644 index 0000000000..1987995927 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/details.html.twig @@ -0,0 +1,38 @@ +{# +/** + * @file + * Theme override for a details element. + * + * Available variables + * - attributes: A list of HTML attributes for the details element. + * - errors: (optional) Any errors for this details element, may not be set. + * - title: (optional) The title of the element, may not be set. + * - summary_attributes: A list of HTML attributes for the summary element. + * - description: (optional) The description of the element, may not be set. + * - children: (optional) The children of the element, may not be set. + * - value: (optional) The value of the element, may not be set. + * + * @see template_preprocess_details() + */ +#} + + {% + set summary_classes = [ + required ? 'js-form-required', + required ? 'form-required', + ] + %} + {%- if title -%} + {{ title }} + {%- endif -%} + + {% if errors %} +
+ {{ errors }} +
+ {% endif %} + + {{ description }} + {{ children }} + {{ value }} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/disclaimer.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/disclaimer.html.twig new file mode 100644 index 0000000000..6f5fdc30eb --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/disclaimer.html.twig @@ -0,0 +1,5 @@ +
+
Disclaimer
+
{{ children.notice }}
+ {{ children.actions }} +
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/fieldset.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/fieldset.html.twig new file mode 100644 index 0000000000..b23402cc0d --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/fieldset.html.twig @@ -0,0 +1,74 @@ +{# +/** + * @file + * Theme override for a fieldset element and its children. + * + * Available variables: + * - attributes: HTML attributes for the
element. + * - errors: (optional) Any errors for this
element, may not be set. + * - required: Boolean indicating whether the
element is required. + * - legend: The element containing the following properties: + * - title: Title of the
, intended for use as the text + of the . + * - attributes: HTML attributes to apply to the element. + * - description: The description element containing the following properties: + * - content: The description content of the
. + * - attributes: HTML attributes to apply to the description container. + * - description_display: Description display setting. It can have these values: + * - before: The description is output before the element. + * - after: The description is output after the element (default). + * - invisible: The description is output after the element, hidden visually + * but available to screen readers. + * - children: The rendered child elements of the
. + * - prefix: The content to add before the
children. + * - suffix: The content to add after the
children. + * + * @see template_preprocess_fieldset() + */ +#} +{% + set classes = [ + 'js-form-item', + 'js-form-wrapper', + 'form-item', + 'form-wrapper', + 'ais-fieldset' + ] +%} + + + {% if legend.title %} + {% + set legend_span_classes = [ + 'fieldset-legend', + required ? 'js-form-required', + required ? 'form-required', + ] + %} + {# Always wrap fieldset legends in a for CSS positioning. #} + + {{ legend.title }} + + {% endif %} + +
+ {% if description_display == 'before' and description.content %} + {{ description.content }}
+ {% endif %} + {% if errors %} +
+ {{ errors }} +
+ {% endif %} + {% if prefix %} + {{ prefix }} + {% endif %} + {{ element }} + {% if suffix %} + {{ suffix }} + {% endif %} + {% if description_display in ['after', 'invisible'] and description.content %} + {{ description.content }} + {% endif %} + +
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element-label.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element-label.html.twig new file mode 100644 index 0000000000..91a051a3fd --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element-label.html.twig @@ -0,0 +1,26 @@ +{# +/** + * @file + * Theme override for a form element label. + * + * Available variables: + * - title: The label's text. + * - title_display: Elements title_display setting. + * - required: An indicator for whether the associated form element is required. + * - attributes: A list of HTML attributes for the label. + * + * @see template_preprocess_form_element_label() + */ +#} +{% + set classes = [ + title_display == 'after' ? 'option', + title_display == 'invisible' ? 'visually-hidden', + required ? 'js-form-required', + required ? 'form-required', + type == 'textarea' ? 'txt-l' + ] +%} +{% if title is not empty or required -%} + {{ title }} +{%- endif %} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element.html.twig new file mode 100644 index 0000000000..99a97fdbac --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form-element.html.twig @@ -0,0 +1,96 @@ +{# +/** + * @file + * Theme override for a form element. + * + * Available variables: + * - attributes: HTML attributes for the containing element. + * - errors: (optional) Any errors for this form element, may not be set. + * - prefix: (optional) The form element prefix, may not be set. + * - suffix: (optional) The form element suffix, may not be set. + * - required: The required marker, or empty if the associated form element is + * not required. + * - type: The type of the element. + * - name: The name of the element. + * - label: A rendered label element. + * - label_display: Label display setting. It can have these values: + * - before: The label is output before the element. This is the default. + * The label includes the #title and the required marker, if #required. + * - after: The label is output after the element. For example, this is used + * for radio and checkbox #type elements. If the #title is empty but the + * field is #required, the label will contain only the required marker. + * - invisible: Labels are critical for screen readers to enable them to + * properly navigate through forms but can be visually distracting. This + * property hides the label for everyone except screen readers. + * - attribute: Set the title attribute on the element to create a tooltip but + * output no label element. This is supported only for checkboxes and radios + * in \Drupal\Core\Render\Element\CompositeFormElementTrait::preRenderCompositeFormElement(). + * It is used where a visual label is not needed, such as a table of + * checkboxes where the row and column provide the context. The tooltip will + * include the title and required marker. + * - description: (optional) A list of description properties containing: + * - content: A description of the form element, may not be set. + * - attributes: (optional) A list of HTML attributes to apply to the + * description content wrapper. Will only be set when description is set. + * - description_display: Description display setting. It can have these values: + * - before: The description is output before the element. + * - after: The description is output after the element. This is the default + * value. + * - invisible: The description is output after the element, hidden visually + * but available to screen readers. + * - disabled: True if the element is disabled. + * - title_display: Title display setting. + * + * @see template_preprocess_form_element() + */ +#} +{% + set classes = [ + 'js-form-item', + 'form-item', + 'form-type-' ~ type|clean_class, + 'js-form-type-' ~ type|clean_class, + 'form-item-' ~ name|clean_class, + 'js-form-item-' ~ name|clean_class, + title_display not in ['after', 'before'] ? 'form-no-label', + disabled == 'disabled' ? 'form-disabled', + errors ? 'form-item--error', + type == 'textarea' ? 'txt' + ] +%} +{% + set description_classes = [ + 'description', + description_display == 'invisible' ? 'visually-hidden', + ] +%} + + {% if label_display in ['before', 'invisible'] %} + {{ label }} + {% endif %} + {% if prefix is not empty %} + {{ prefix }} + {% endif %} + {% if description_display == 'before' and description.content %} + + {{ description.content }} + + {% endif %} + {{ children }} + {% if suffix is not empty %} + {{ suffix }} + {% endif %} + {% if label_display == 'after' %} + {{ label }} + {% endif %} + {% if errors %} +
+ {{ errors }} +
+ {% endif %} + {% if description_display in ['after', 'invisible'] and description.content %} + + {{ description.content }} + + {% endif %} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form.html.twig new file mode 100644 index 0000000000..655b5ccf75 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/form.html.twig @@ -0,0 +1,75 @@ +{# +/** + * @file + * Theme override for a 'form' element. + * + * Available variables + * - attributes: A list of HTML attributes for the wrapper element. + * - configuration: The configuration for the block, including preset + * - preset: The preset used by this form instance + * - children: The child elements of the form. + * + * @see template_preprocess_form() + * @see bos_search_preprocess + */ +#} +{% if form_header %} + {{ form_header }} +{% endif %} + + + + {% block SearchContent %} + {{ element.AiSearchForm.content.messages }} + {{ element.AiSearchForm.content.preset }} + {{ element.AiSearchForm.content.session_id }} + + {% block SearchResults %} +
+
+ + {% if element.AiSearchForm.content.searchresults.welcome.title or element.AiSearchForm.content.searchresults.welcome.body %} +
+ + {% if element.AiSearchForm.content.searchresults.welcome.title %} +
+ {{ element.AiSearchForm.content.searchresults.welcome.title }} +
+ {% endif %} + + {% if element.AiSearchForm.content.searchresults.welcome.body %} +
+ {{ element.AiSearchForm.content.searchresults.welcome.body }} +
+ {% endif %} + +
+ {% endif %} + + {% if element.AiSearchForm.content.searchresults.welcome.cards %} +
+ {{ element.AiSearchForm.content.searchresults.welcome.cards }} +
+ {% endif %} + +
+
+ +
+ + {% endblock %} + {% endblock %} + + {% block SearchBar %} + {{ element.AiSearchForm.searchbar }} + {% endblock %} + + {% block Actions %} + {{ element.AiSearchForm.actions }} + {% endblock %} + + {{ element.form_id }} + {{ element.form_build_id }} + {{ element.form_token }} + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/input.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/input.html.twig new file mode 100644 index 0000000000..2722f0be54 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/input.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Theme override for an 'input' #type form element. + * + * Available variables: + * - attributes: A list of HTML attributes for the input element. + * - children: Optional additional rendered elements. + * + * @see template_preprocess_input() + */ +#} + +{{ children }} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/js/preset.js b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/js/preset.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/radios.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/radios.html.twig new file mode 100644 index 0000000000..6e9a9d795b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/radios.html.twig @@ -0,0 +1,13 @@ +{# +/** + * @file + * Theme override for a 'radios' #type form element. + * + * Available variables + * - attributes: A list of HTML attributes for the wrapper element. + * - children: The rendered radios. + * + * @see template_preprocess_radios() + */ +#} +{{ children }} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/results.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/results.html.twig new file mode 100644 index 0000000000..fc03ce51b6 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/results.html.twig @@ -0,0 +1,143 @@ +{# +/** + * @file + * Template for the Search Results Output. + * + * Available variables: + * - content: The question prompting this response. + * - response: The AI generated response as text. + * - items: An array of serach results, each element has + * - "content" => Extract from page defined by "link" + * - "id" => A unique id for the result + * - "link" => A link to the page result + * - "link_title" => A title for the page result, usually to the canonical homepage + * - "ref" => The reference to the result from the AI Model + * - "summary" => An annotated summary of the page result + * - "body" => A plain text summary of the page result + * - "title" => A title for the page result + * - metadata: An array with metadata returned by the AI Model. + * - references: An array with references returned by the AI Model. + * - citations: An array with citations returned by the AI Model. + * - id: The conversation ID for persistence. + */ +#} + +{% set ran = random(0,10000000) %} +
+ {% if response %} +
+
SUMMARY
+
{{ response|raw }}
+{#
#} +
+ +
+ {% if citations %} + +
SOURCE LINKS
+
+ + + +
+ {% for key,citation in citations %} + {% if citation %} + [{{ citation.seq }}] {{ citation.title }} + {% endif %} + {% endfor %} +
+
+ Show All +
+ +
+ + {% endif %} +
+ + {% if feedback %} + {{ feedback }} + {% endif %} + + {% endif %} +
+ +
+ {% if items %} + + {% endif %} + + {% if metadata %} + + +
+ {% endif %} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/select.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/select.html.twig new file mode 100644 index 0000000000..9c8a97c058 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/select.html.twig @@ -0,0 +1,27 @@ +{# +/** + * @file + * Theme override for a select element. + * + * Available variables: + * - attributes: HTML attributes for the +{% endapply %} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/textarea.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/textarea.html.twig new file mode 100644 index 0000000000..89c9af1ae1 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/concierge/textarea.html.twig @@ -0,0 +1,25 @@ +{# +/** + * @file + * Theme override for a 'textarea' #type form element. + * + * Available variables + * - wrapper_attributes: A list of HTML attributes for the wrapper element. + * - attributes: A list of HTML attributes for the + +{% if wrapper_attributes %} + +{% endif %} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/block--button.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/block--button.html.twig new file mode 100644 index 0000000000..69f9923f3f --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/block--button.html.twig @@ -0,0 +1,37 @@ +{# +/** + * @file + * Theme for the AI Search Button Block. + * + * Available variables: + * - plugin_id: The ID of the block implementation. + * - label: The configured label of the block if visible. + * - configuration: A list of the block's configuration values. + * - label: The configured label for the block. + * - label_display: The display settings for the label. + * - provider: The module or other provider that provided this block plugin. + * - Block plugin specific settings will also be stored here. + * - content: The content of this block. + * - attributes: array of HTML attributes populated by modules, intended to + * be added to the main container tag of this template. + * - id: A valid HTML ID and guaranteed unique. + * - title_attributes: Same as attributes, except applied to the main title + * tag that appears in the template. + * - title_prefix: Additional output populated by modules, intended to be + * displayed in front of the main title tag that appears in the template. + * - title_suffix: Additional output populated by modules, intended to be + * displayed after the main title tag that appears in the template. + * + * @see template_preprocess_block() + */ +#} + + {{ title_prefix }} + {% if label %} + {{ label }} + {% endif %} + {{ title_suffix }} + {% block content %} + {{ content }} + {% endblock %} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/block--form.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/block--form.html.twig new file mode 100644 index 0000000000..295be5ae58 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/block--form.html.twig @@ -0,0 +1,38 @@ +{# +/** + * @file + * Theme for the AI Search Form Block. + * + * Available variables: + * - plugin_id: The ID of the block implementation. + * - label: The configured label of the block if visible. + * - configuration: A list of the block's configuration values. + * - label: The configured label for the block. + * - label_display: The display settings for the label. + * - provider: The module or other provider that provided this block plugin. + * - Block plugin specific settings will also be stored here. + * - content: The content of this block. + * - attributes: array of HTML attributes populated by modules, intended to + * be added to the main container tag of this template. + * - id: A valid HTML ID and guaranteed unique. + * - title_attributes: Same as attributes, except applied to the main title + * tag that appears in the template. + * - title_prefix: Additional output populated by modules, intended to be + * displayed in front of the main title tag that appears in the template. + * - title_suffix: Additional output populated by modules, intended to be + * displayed after the main title tag that appears in the template. + * + * @see template_preprocess_block() + */ +#} +{% set classes = ['block','block-aisearchform','aienabledsearchform'] %} + + {{ title_prefix }} + {% if label %} + {{ label }} + {% endif %} + {{ title_suffix }} + {% block content %} + {{ content }} + {% endblock %} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/checkboxes.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/checkboxes.html.twig new file mode 100644 index 0000000000..196dc6f96e --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/checkboxes.html.twig @@ -0,0 +1,13 @@ +{# +/** + * @file + * Theme override for a 'checkboxes' #type form element. + * + * Available variables + * - attributes: A list of HTML attributes for the wrapper element. + * - children: The rendered checkboxes. + * + * @see template_preprocess_checkboxes() + */ +#} +{{ children }} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/container.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/container.html.twig new file mode 100644 index 0000000000..0da6c388d0 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/container.html.twig @@ -0,0 +1,28 @@ +{# +/** + * @file + * Theme override of a container used to wrap child elements. + * + * Used for grouped form items. Can also be used as a theme wrapper for any + * renderable element, to surround it with a
and HTML attributes. + * See \Drupal\Core\Render\Element\RenderElement for more + * information on the #theme_wrappers render array property, and + * \Drupal\Core\Render\Element\container for usage of the container render + * element. + * + * Available variables: + * - attributes: HTML attributes for the containing element. + * - children: The rendered child elements of the container. + * - has_parent: A flag to indicate that the container has one or more parent + containers. + * + * @see template_preprocess_container() + */ +#} +{% + set classes = [ + has_parent ? 'js-form-wrapper', + has_parent ? 'form-wrapper', + ] +%} +{{ children }}
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/css/preset.css b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/css/preset.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/details.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/details.html.twig new file mode 100644 index 0000000000..1987995927 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/details.html.twig @@ -0,0 +1,38 @@ +{# +/** + * @file + * Theme override for a details element. + * + * Available variables + * - attributes: A list of HTML attributes for the details element. + * - errors: (optional) Any errors for this details element, may not be set. + * - title: (optional) The title of the element, may not be set. + * - summary_attributes: A list of HTML attributes for the summary element. + * - description: (optional) The description of the element, may not be set. + * - children: (optional) The children of the element, may not be set. + * - value: (optional) The value of the element, may not be set. + * + * @see template_preprocess_details() + */ +#} + + {% + set summary_classes = [ + required ? 'js-form-required', + required ? 'form-required', + ] + %} + {%- if title -%} + {{ title }} + {%- endif -%} + + {% if errors %} +
+ {{ errors }} +
+ {% endif %} + + {{ description }} + {{ children }} + {{ value }} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/disclaimer.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/disclaimer.html.twig new file mode 100644 index 0000000000..6f5fdc30eb --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/disclaimer.html.twig @@ -0,0 +1,5 @@ +
+
Disclaimer
+
{{ children.notice }}
+ {{ children.actions }} +
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/fieldset.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/fieldset.html.twig new file mode 100644 index 0000000000..b23402cc0d --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/fieldset.html.twig @@ -0,0 +1,74 @@ +{# +/** + * @file + * Theme override for a fieldset element and its children. + * + * Available variables: + * - attributes: HTML attributes for the
element. + * - errors: (optional) Any errors for this
element, may not be set. + * - required: Boolean indicating whether the
element is required. + * - legend: The element containing the following properties: + * - title: Title of the
, intended for use as the text + of the . + * - attributes: HTML attributes to apply to the element. + * - description: The description element containing the following properties: + * - content: The description content of the
. + * - attributes: HTML attributes to apply to the description container. + * - description_display: Description display setting. It can have these values: + * - before: The description is output before the element. + * - after: The description is output after the element (default). + * - invisible: The description is output after the element, hidden visually + * but available to screen readers. + * - children: The rendered child elements of the
. + * - prefix: The content to add before the
children. + * - suffix: The content to add after the
children. + * + * @see template_preprocess_fieldset() + */ +#} +{% + set classes = [ + 'js-form-item', + 'js-form-wrapper', + 'form-item', + 'form-wrapper', + 'ais-fieldset' + ] +%} + + + {% if legend.title %} + {% + set legend_span_classes = [ + 'fieldset-legend', + required ? 'js-form-required', + required ? 'form-required', + ] + %} + {# Always wrap fieldset legends in a for CSS positioning. #} + + {{ legend.title }} + + {% endif %} + +
+ {% if description_display == 'before' and description.content %} + {{ description.content }}
+ {% endif %} + {% if errors %} +
+ {{ errors }} +
+ {% endif %} + {% if prefix %} + {{ prefix }} + {% endif %} + {{ element }} + {% if suffix %} + {{ suffix }} + {% endif %} + {% if description_display in ['after', 'invisible'] and description.content %} + {{ description.content }} + {% endif %} + +
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form-element-label.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form-element-label.html.twig new file mode 100644 index 0000000000..91a051a3fd --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form-element-label.html.twig @@ -0,0 +1,26 @@ +{# +/** + * @file + * Theme override for a form element label. + * + * Available variables: + * - title: The label's text. + * - title_display: Elements title_display setting. + * - required: An indicator for whether the associated form element is required. + * - attributes: A list of HTML attributes for the label. + * + * @see template_preprocess_form_element_label() + */ +#} +{% + set classes = [ + title_display == 'after' ? 'option', + title_display == 'invisible' ? 'visually-hidden', + required ? 'js-form-required', + required ? 'form-required', + type == 'textarea' ? 'txt-l' + ] +%} +{% if title is not empty or required -%} + {{ title }} +{%- endif %} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form-element.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form-element.html.twig new file mode 100644 index 0000000000..99a97fdbac --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form-element.html.twig @@ -0,0 +1,96 @@ +{# +/** + * @file + * Theme override for a form element. + * + * Available variables: + * - attributes: HTML attributes for the containing element. + * - errors: (optional) Any errors for this form element, may not be set. + * - prefix: (optional) The form element prefix, may not be set. + * - suffix: (optional) The form element suffix, may not be set. + * - required: The required marker, or empty if the associated form element is + * not required. + * - type: The type of the element. + * - name: The name of the element. + * - label: A rendered label element. + * - label_display: Label display setting. It can have these values: + * - before: The label is output before the element. This is the default. + * The label includes the #title and the required marker, if #required. + * - after: The label is output after the element. For example, this is used + * for radio and checkbox #type elements. If the #title is empty but the + * field is #required, the label will contain only the required marker. + * - invisible: Labels are critical for screen readers to enable them to + * properly navigate through forms but can be visually distracting. This + * property hides the label for everyone except screen readers. + * - attribute: Set the title attribute on the element to create a tooltip but + * output no label element. This is supported only for checkboxes and radios + * in \Drupal\Core\Render\Element\CompositeFormElementTrait::preRenderCompositeFormElement(). + * It is used where a visual label is not needed, such as a table of + * checkboxes where the row and column provide the context. The tooltip will + * include the title and required marker. + * - description: (optional) A list of description properties containing: + * - content: A description of the form element, may not be set. + * - attributes: (optional) A list of HTML attributes to apply to the + * description content wrapper. Will only be set when description is set. + * - description_display: Description display setting. It can have these values: + * - before: The description is output before the element. + * - after: The description is output after the element. This is the default + * value. + * - invisible: The description is output after the element, hidden visually + * but available to screen readers. + * - disabled: True if the element is disabled. + * - title_display: Title display setting. + * + * @see template_preprocess_form_element() + */ +#} +{% + set classes = [ + 'js-form-item', + 'form-item', + 'form-type-' ~ type|clean_class, + 'js-form-type-' ~ type|clean_class, + 'form-item-' ~ name|clean_class, + 'js-form-item-' ~ name|clean_class, + title_display not in ['after', 'before'] ? 'form-no-label', + disabled == 'disabled' ? 'form-disabled', + errors ? 'form-item--error', + type == 'textarea' ? 'txt' + ] +%} +{% + set description_classes = [ + 'description', + description_display == 'invisible' ? 'visually-hidden', + ] +%} + + {% if label_display in ['before', 'invisible'] %} + {{ label }} + {% endif %} + {% if prefix is not empty %} + {{ prefix }} + {% endif %} + {% if description_display == 'before' and description.content %} + + {{ description.content }} + + {% endif %} + {{ children }} + {% if suffix is not empty %} + {{ suffix }} + {% endif %} + {% if label_display == 'after' %} + {{ label }} + {% endif %} + {% if errors %} +
+ {{ errors }} +
+ {% endif %} + {% if description_display in ['after', 'invisible'] and description.content %} + + {{ description.content }} + + {% endif %} + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form.html.twig new file mode 100644 index 0000000000..301cab41d7 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/form.html.twig @@ -0,0 +1,50 @@ +{# +/** + * @file + * Theme override for a 'form' element. + * + * Available variables + * - attributes: A list of HTML attributes for the wrapper element. + * - configuration: The configuration for the block, including preset + * - preset: The preset used by this form instance + * - children: The child elements of the form. + * + * @see template_preprocess_form() + * @see bos_search_preprocess + */ +#} +{% if form_header %} + {{ form_header }} +{% endif %} + + + + {% block SearchBar %} + {{ element.AiSearchForm.searchbar }} + {% endblock %} + + {% block Actions %} + {{ element.AiSearchForm.actions }} + {% endblock %} + + {% block SearchContent %} + {{ element.AiSearchForm.content.messages }} + {{ element.AiSearchForm.content.preset }} + {{ element.AiSearchForm.content.session_id }} + + {% block SearchResults %} +
+
+ {{ element.AiSearchForm.content.searchresults.welcome.title }} + {{ element.AiSearchForm.content.searchresults.welcome.cards }} +
+
+ {% endblock %} + + {% endblock %} + + {{ element.form_id }} + {{ element.form_build_id }} + {{ element.form_token }} + + diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/input.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/input.html.twig new file mode 100644 index 0000000000..d5cac386e0 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/input.html.twig @@ -0,0 +1,13 @@ +{# +/** + * @file + * Theme override for an 'input' #type form element. + * + * Available variables: + * - attributes: A list of HTML attributes for the input element. + * - children: Optional additional rendered elements. + * + * @see template_preprocess_input() + */ +#} +{{ children }} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/js/preset.js b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/js/preset.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/radios.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/radios.html.twig new file mode 100644 index 0000000000..6e9a9d795b --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/radios.html.twig @@ -0,0 +1,13 @@ +{# +/** + * @file + * Theme override for a 'radios' #type form element. + * + * Available variables + * - attributes: A list of HTML attributes for the wrapper element. + * - children: The rendered radios. + * + * @see template_preprocess_radios() + */ +#} +{{ children }} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/results.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/results.html.twig new file mode 100644 index 0000000000..b8cea9f57c --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/results.html.twig @@ -0,0 +1,84 @@ +{# +/** + * @file + * Template for the Search Results Output. + * + * Available variables: + * - content: The question prompting this response. + * - response: The AI generated response as text. + * - items: An array of serach results, each element has + * - "content" => Extract from page defined by "link" + * - "id" => A unique id for the result + * - "link" => A link to the page result + * - "link_title" => A title for the page result, usually to the canonical homepage + * - "ref" => The reference to the result from the AI Model + * - "summary" => A summary of the page result + * - "title" => A title for the page result + * - metadata: An array with metadata returned by the AI Model. + * - references: An array with references returned by the AI Model. + * - citations: An array with citations returned by the AI Model. + * - id: The conversation ID for persistence. + */ +#} +
+ +
+
{{ content }}
+
+ +
+ {% if response %} +
+ {{ response }} + +{#
#} + + {{ feedback }} + + {% if citations %} +
+ {% for key,citation in citations %} + {% if citation %} +
{{ key }}:{{ citation.title }}
+ {% endif %} + {% endfor %} +
+ {% endif %} +
+ {% endif %} + +
+
Useful Links
+
    + {% for key,item in items %} +
    +
  1. + {{ item.title }} +
    {{ item.summary|raw }}
    + {{ item.link }} + Page Link +
  2. +
    + {% endfor %} +
+
+ + {% if metadata %} + + {% endif %} + +
+ +
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/select.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/select.html.twig new file mode 100644 index 0000000000..9c8a97c058 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/select.html.twig @@ -0,0 +1,27 @@ +{# +/** + * @file + * Theme override for a select element. + * + * Available variables: + * - attributes: HTML attributes for the +{% endapply %} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/textarea.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/textarea.html.twig new file mode 100644 index 0000000000..89c9af1ae1 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/presets/search1/textarea.html.twig @@ -0,0 +1,25 @@ +{# +/** + * @file + * Theme override for a 'textarea' #type form element. + * + * Available variables + * - wrapper_attributes: A list of HTML attributes for the wrapper element. + * - attributes: A list of HTML attributes for the + +{% if wrapper_attributes %} + +{% endif %} diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/aisearch-button.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/aisearch-button.html.twig new file mode 100644 index 0000000000..641c418bf9 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/aisearch-button.html.twig @@ -0,0 +1,15 @@ +{{ attach_library("bos_search/snippet.search_button") }} + +{% if display == "modal" %} + {% set query = "?preset=#{ preset }&title=#{ button_title }&display=#{ display }" %} + {% set button_css = button_css ~ ' use-ajax' %} +{% else %} + {% set query = "?preset=#{ preset }&display=#{ display }" %} +{% endif %} + +
+ {{ button_title }} + {% if body %} + {{ body|raw }} + {% endif %} +
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/aisearch-feedback.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/aisearch-feedback.html.twig new file mode 100644 index 0000000000..ec0ac78148 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/aisearch-feedback.html.twig @@ -0,0 +1,36 @@ +{{ attach_library("bos_search/snippet.search_feedback") }} +{% set dialogOptions = " data-dialog-options=" ~ {'width':345,'classes':{'ui-dialog':'feedback-dialog'},'title':''}|json_encode %} +
+ +
+ {% if thumbsup %} + +
+ + + +
+
+ {% endif %} + {% if thumbsdown %} + +
+ + + +
+
+ {% endif %} + {% if thumbsup or thumbsdown %} +
Was this helpful?
+ {% endif %} +
+ +
+
+ +
diff --git a/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/modal-close.html.twig b/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/modal-close.html.twig new file mode 100644 index 0000000000..9868509a57 --- /dev/null +++ b/docroot/modules/custom/bos_components/modules/bos_search/templates/snippets/modal-close.html.twig @@ -0,0 +1,10 @@ + +{{ attach_library("bos_search/snippet.modal_close") }} + + +
diff --git a/docroot/modules/custom/bos_core/src/Controllers/Curl/BosCurlControllerBase.php b/docroot/modules/custom/bos_core/src/Controllers/Curl/BosCurlControllerBase.php index c21232e447..e4f74b61f6 100644 --- a/docroot/modules/custom/bos_core/src/Controllers/Curl/BosCurlControllerBase.php +++ b/docroot/modules/custom/bos_core/src/Controllers/Curl/BosCurlControllerBase.php @@ -80,7 +80,7 @@ public function __construct(array $default_headers = [], bool $get_response_head * @return CurlHandle * @throws Exception */ - public function makeCurl(string $post_url, array|string $post_fields, array $headers = [], string $type = "POST", bool $insecure = FALSE): CurlHandle { + public function makeCurl(string $post_url, array|string|NULL $post_fields, array $headers = [], string $type = "POST", bool $insecure = FALSE): CurlHandle { // Merge and encode the headers for CuRL. // Any supplied headers will overwrite the defaults headers. @@ -126,7 +126,9 @@ public function makeCurl(string $post_url, array|string $post_fields, array $hea curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $type); curl_setopt($ch, CURLOPT_URL, $post_url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); - curl_setopt($ch, CURLOPT_POSTFIELDS, $post_fields); + if ($type == "POST") { + curl_setopt($ch, CURLOPT_POSTFIELDS, $post_fields); + } curl_setopt($ch, CURLOPT_HTTPHEADER, $_headers); curl_setopt($ch, CURLOPT_HEADER, $this->get_response_headers); curl_setopt($ch, CURLINFO_HEADER_OUT, $this->get_response_headers); @@ -334,6 +336,16 @@ public function post(string $post_url, array|string $post_fields, array $headers } + public function get(string $post_url, NULL|array|string $post_fields, array $headers = []): array|bool { + try { + $this->makeCurl($post_url, $post_fields, $headers, "GET", FALSE); + return $this->executeCurl(FALSE); + } + catch (Exception $e) { + return FALSE; + } + + } /** * Errors from most recent CuRL operation. *