diff --git a/CHANGELOG.md b/CHANGELOG.md index bce107126..16d61ca8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Introduce new `openai_generate` action to get responses to a prompt/question from OpenAI API. [#621] ### Bug Fixes @@ -24,7 +24,7 @@ _None_ ### Bug Fixes -- `DateVersionCalculator`: move next year calculation decision to the clients [#619] +- `DateVersionCalculator`: move next year calculation decision to the clients. [#619] ### Internal Changes @@ -34,8 +34,8 @@ _None_ ### Bug Fixes -- Fix `check_fonts_installed` step in `create_promo_screenshots` [#615] -- Fix broken `draw_text_to_canvas` method for `create_promo_screenshots` [#614] +- Fix `check_fonts_installed` step in `create_promo_screenshots`. [#615] +- Fix broken `draw_text_to_canvas` method for `create_promo_screenshots`. [#614] ## 12.3.2 diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/openai_ask_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/openai_ask_action.rb new file mode 100644 index 000000000..9c09ed58e --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/openai_ask_action.rb @@ -0,0 +1,141 @@ +require 'fastlane/action' +require 'net/http' +require 'json' + +module Fastlane + module Actions + class OpenaiAskAction < Action + OPENAI_API_ENDPOINT = URI('https://api.openai.com/v1/chat/completions').freeze + + PREDEFINED_PROMPTS = { + release_notes: <<~PROMPT.freeze + Act like a mobile app marketer who wants to prepare release notes for Google Play and App Store. + Do not write it point by point and keep it under 350 characters. It should be a unique paragraph. + + When provided a list, use the number of any potential "*" in brackets at the start of each item as indicator of importance. + Ignore items starting with "[Internal]", and ignore links to GitHub. + PROMPT + }.freeze + + def self.run(params) + api_token = params[:api_token] + prompt = params[:prompt] + prompt = PREDEFINED_PROMPTS[prompt] if PREDEFINED_PROMPTS.key?(prompt) + question = params[:question] + + headers = { + 'Content-Type': 'application/json', + Authorization: "Bearer #{api_token}" + } + body = request_body(prompt: prompt, question: question) + + response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers) + + case response + when Net::HTTPOK + json = JSON.parse(response.body) + json['choices']&.first&.dig('message', 'content') + else + UI.user_error!("Error in OpenAI API response: #{response}. #{response.body}") + end + end + + def self.request_body(prompt:, question:) + { + model: 'gpt-4o', + response_format: { type: 'text' }, + temperature: 1, + max_tokens: 2048, + top_p: 1, + messages: [ + format_message(role: 'system', text: prompt), + format_message(role: 'user', text: question), + ].compact + }.to_json + end + + def self.format_message(role:, text:) + return nil if text.nil? || text.empty? + + { + role: role, + content: [{ type: 'text', text: text }] + } + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Use OpenAI API to generate response to a prompt' + end + + def self.authors + ['Automattic'] + end + + def self.return_value + 'The response text from the prompt as returned by OpenAI API' + end + + def self.details + <<~DETAILS + Uses the OpenAI API to generate response to a prompt. + Can be used to e.g. ask it to generate Release Notes based on a bullet point technical changelog or similar. + DETAILS + end + + def self.examples + [ + <<~EXEMPLE, + items = extract_release_notes_for_version(version: app_version, release_notes_file_path: 'RELEASE-NOTES.txt') + nice_changelog = openai_ask( + prompt: :release_notes, # Uses the pre-crafted prompt for App Store / Play Store release notes + question: "Help me write release notes for the following items:\n#{items}", + api_token: get_required_env('OPENAI_API_TOKEN') + ) + File.write(File.join('fastlane', 'metadata', 'android', 'en-US', 'changelogs', 'default.txt'), nice_changelog) + EXEMPLE + ] + end + + def self.available_prompt_symbols + PREDEFINED_PROMPTS.keys.map { |v| "`:#{v}`" }.join(',') + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new(key: :prompt, + description: 'The internal top-level instructions to give to the model to tell it how to behave. ' \ + + "Use a Ruby Symbol from one of [#{available_prompt_symbols}] to use a predefined prompt instead of writing your own", + optional: true, + default_value: nil, + type: String, + skip_type_validation: true, + verify_block: proc do |value| + next if value.is_a?(String) + next if PREDEFINED_PROMPTS.include?(value) + + UI.user_error!("Parameter `prompt` can only be a String or one of the following Symbols: [#{available_prompt_symbols}]") + end), + FastlaneCore::ConfigItem.new(key: :question, + description: 'The user message to ask the question to the OpenAI model', + optional: false, + default_value: nil, + type: String), + FastlaneCore::ConfigItem.new(key: :api_token, + description: 'The OpenAI API Token to use for the request', + env_name: 'OPENAI_API_TOKEN', + optional: false, + sensitive: true, + type: String), + ] + end + + def self.is_supported?(_platform) + true + end + end + end +end diff --git a/spec/openai_ask_action_spec.rb b/spec/openai_ask_action_spec.rb new file mode 100644 index 000000000..46c79f671 --- /dev/null +++ b/spec/openai_ask_action_spec.rb @@ -0,0 +1,116 @@ +require 'spec_helper' + +describe Fastlane::Actions::OpenaiAskAction do + let(:fake_token) { 'sk-proj-faketok' } + let(:endpoint) { Fastlane::Actions::OpenaiAskAction::OPENAI_API_ENDPOINT } + + def stubbed_response(text) + <<~JSON + { + "id": "chatcmpl-Aa2NPY4sSWF5eKoW1aFBJmfc78y9p", + "object": "chat.completion", + "created": 1733152307, + "model": "gpt-4o-2024-08-06", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": #{text.to_json}, + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 91, + "completion_tokens": 68, + "total_tokens": 159, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "system_fingerprint": "fp_831e067d82" + } + JSON + end + + def run_test(prompt_param:, question_param:, expected_prompt:, expected_response:) + expected_req_body = described_class.request_body(prompt: expected_prompt, question: question_param) + + stub = stub_request(:post, endpoint) + .with(body: expected_req_body) + .to_return(status: 200, body: stubbed_response(expected_response)) + + result = run_described_fastlane_action( + api_token: fake_token, + prompt: prompt_param, + question: question_param + ) + + # Ensure the body of the request contains the expected JSON data + messages = JSON.parse(expected_req_body)['messages'] + if expected_prompt.nil? || expected_prompt.empty? + expect(messages.length).to eq(1) + expect(messages[0]['role']).to eq('user') + expect(messages[0]['content']).to eq(['type' => 'text', 'text' => question_param]) + else + expect(messages.length).to eq(2) + expect(messages[0]['role']).to eq('system') + expect(messages[0]['content']).to eq(['type' => 'text', 'text' => expected_prompt]) + expect(messages[1]['role']).to eq('user') + expect(messages[1]['content']).to eq(['type' => 'text', 'text' => question_param]) + end + + # Ensure the request has been made and return the action response for it to be validated in calling test + expect(stub).to have_been_requested + result + end + + it 'calls the API with no prompt' do + result = run_test( + prompt_param: '', + question_param: 'Say Hi', + expected_prompt: nil, + expected_response: 'Hello! How can I assist you today?' + ) + + expect(result).to eq('Hello! How can I assist you today?') + end + + it 'calls the API with :release_notes prompt' do + changelog = <<~CHANGELOG + - [Internal] Fetch remote FF on site change [https://github.com/woocommerce/woocommerce-android/pull/12751] + - [**] Improve barcode scanner reading accuracy [https://github.com/woocommerce/woocommerce-android/pull/12673] + - [Internal] AI product creation banner is removed [https://github.com/woocommerce/woocommerce-android/pull/12705] + - [*] [Login] Fix an issue where the app doesn't show the correct error screen when application passwords are disabled [https://github.com/woocommerce/woocommerce-android/pull/12717] + - [**] Fixed bug with coupons disappearing from the order creation screen unexpectedly [https://github.com/woocommerce/woocommerce-android/pull/12724] + - [Internal] Fixes crash [https://github.com/woocommerce/woocommerce-android/issues/12715] + - [*] Fixed incorrect instructions on "What is Tap to Pay" screen in the Payments section [https://github.com/woocommerce/woocommerce-android/pull/12709] + - [***] Merchants can now view and edit custom fields of their products and orders from the app [https://github.com/woocommerce/woocommerce-android/issues/12207] + - [*] Fix size of the whats new announcement dialog [https://github.com/woocommerce/woocommerce-android/pull/12692] + - [*] Enables Blaze survey [https://github.com/woocommerce/woocommerce-android/pull/12761] + CHANGELOG + + expected_response = <<~TEXT + Exciting updates are here! We've enhanced the barcode scanner for optimal accuracy and resolved the issue with coupons vanishing during order creation. Most significantly, merchants can now effortlessly view and edit custom fields for products and orders directly within the app. Additionally, we've improved error handling on login and fixed various UI inconsistencies. Enjoy a smoother experience! + TEXT + + result = run_test( + prompt_param: :release_notes, + question_param: "Help me write release notes for the following items:\n#{changelog}", + expected_prompt: Fastlane::Actions::OpenaiAskAction::PREDEFINED_PROMPTS[:release_notes], + expected_response: expected_response + ) + + expect(result).to eq(expected_response) + end +end