Skip to content

Commit b8f7fcd

Browse files
authored
Introduce OpenAI action (#621)
2 parents 71095be + c829b8d commit b8f7fcd

3 files changed

Lines changed: 261 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
- Introduce new `openai_generate` action to get responses to a prompt/question from OpenAI API. [#621]
1414

1515
### Bug Fixes
1616

@@ -24,7 +24,7 @@ _None_
2424

2525
### Bug Fixes
2626

27-
- `DateVersionCalculator`: move next year calculation decision to the clients [#619]
27+
- `DateVersionCalculator`: move next year calculation decision to the clients. [#619]
2828

2929
### Internal Changes
3030

@@ -34,8 +34,8 @@ _None_
3434

3535
### Bug Fixes
3636

37-
- Fix `check_fonts_installed` step in `create_promo_screenshots` [#615]
38-
- Fix broken `draw_text_to_canvas` method for `create_promo_screenshots` [#614]
37+
- Fix `check_fonts_installed` step in `create_promo_screenshots`. [#615]
38+
- Fix broken `draw_text_to_canvas` method for `create_promo_screenshots`. [#614]
3939

4040
## 12.3.2
4141

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
require 'fastlane/action'
2+
require 'net/http'
3+
require 'json'
4+
5+
module Fastlane
6+
module Actions
7+
class OpenaiAskAction < Action
8+
OPENAI_API_ENDPOINT = URI('https://api.openai.com/v1/chat/completions').freeze
9+
10+
PREDEFINED_PROMPTS = {
11+
release_notes: <<~PROMPT.freeze
12+
Act like a mobile app marketer who wants to prepare release notes for Google Play and App Store.
13+
Do not write it point by point and keep it under 350 characters. It should be a unique paragraph.
14+
15+
When provided a list, use the number of any potential "*" in brackets at the start of each item as indicator of importance.
16+
Ignore items starting with "[Internal]", and ignore links to GitHub.
17+
PROMPT
18+
}.freeze
19+
20+
def self.run(params)
21+
api_token = params[:api_token]
22+
prompt = params[:prompt]
23+
prompt = PREDEFINED_PROMPTS[prompt] if PREDEFINED_PROMPTS.key?(prompt)
24+
question = params[:question]
25+
26+
headers = {
27+
'Content-Type': 'application/json',
28+
Authorization: "Bearer #{api_token}"
29+
}
30+
body = request_body(prompt: prompt, question: question)
31+
32+
response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers)
33+
34+
case response
35+
when Net::HTTPOK
36+
json = JSON.parse(response.body)
37+
json['choices']&.first&.dig('message', 'content')
38+
else
39+
UI.user_error!("Error in OpenAI API response: #{response}. #{response.body}")
40+
end
41+
end
42+
43+
def self.request_body(prompt:, question:)
44+
{
45+
model: 'gpt-4o',
46+
response_format: { type: 'text' },
47+
temperature: 1,
48+
max_tokens: 2048,
49+
top_p: 1,
50+
messages: [
51+
format_message(role: 'system', text: prompt),
52+
format_message(role: 'user', text: question),
53+
].compact
54+
}.to_json
55+
end
56+
57+
def self.format_message(role:, text:)
58+
return nil if text.nil? || text.empty?
59+
60+
{
61+
role: role,
62+
content: [{ type: 'text', text: text }]
63+
}
64+
end
65+
66+
#####################################################
67+
# @!group Documentation
68+
#####################################################
69+
70+
def self.description
71+
'Use OpenAI API to generate response to a prompt'
72+
end
73+
74+
def self.authors
75+
['Automattic']
76+
end
77+
78+
def self.return_value
79+
'The response text from the prompt as returned by OpenAI API'
80+
end
81+
82+
def self.details
83+
<<~DETAILS
84+
Uses the OpenAI API to generate response to a prompt.
85+
Can be used to e.g. ask it to generate Release Notes based on a bullet point technical changelog or similar.
86+
DETAILS
87+
end
88+
89+
def self.examples
90+
[
91+
<<~EXEMPLE,
92+
items = extract_release_notes_for_version(version: app_version, release_notes_file_path: 'RELEASE-NOTES.txt')
93+
nice_changelog = openai_ask(
94+
prompt: :release_notes, # Uses the pre-crafted prompt for App Store / Play Store release notes
95+
question: "Help me write release notes for the following items:\n#{items}",
96+
api_token: get_required_env('OPENAI_API_TOKEN')
97+
)
98+
File.write(File.join('fastlane', 'metadata', 'android', 'en-US', 'changelogs', 'default.txt'), nice_changelog)
99+
EXEMPLE
100+
]
101+
end
102+
103+
def self.available_prompt_symbols
104+
PREDEFINED_PROMPTS.keys.map { |v| "`:#{v}`" }.join(',')
105+
end
106+
107+
def self.available_options
108+
[
109+
FastlaneCore::ConfigItem.new(key: :prompt,
110+
description: 'The internal top-level instructions to give to the model to tell it how to behave. ' \
111+
+ "Use a Ruby Symbol from one of [#{available_prompt_symbols}] to use a predefined prompt instead of writing your own",
112+
optional: true,
113+
default_value: nil,
114+
type: String,
115+
skip_type_validation: true,
116+
verify_block: proc do |value|
117+
next if value.is_a?(String)
118+
next if PREDEFINED_PROMPTS.include?(value)
119+
120+
UI.user_error!("Parameter `prompt` can only be a String or one of the following Symbols: [#{available_prompt_symbols}]")
121+
end),
122+
FastlaneCore::ConfigItem.new(key: :question,
123+
description: 'The user message to ask the question to the OpenAI model',
124+
optional: false,
125+
default_value: nil,
126+
type: String),
127+
FastlaneCore::ConfigItem.new(key: :api_token,
128+
description: 'The OpenAI API Token to use for the request',
129+
env_name: 'OPENAI_API_TOKEN',
130+
optional: false,
131+
sensitive: true,
132+
type: String),
133+
]
134+
end
135+
136+
def self.is_supported?(_platform)
137+
true
138+
end
139+
end
140+
end
141+
end

spec/openai_ask_action_spec.rb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
require 'spec_helper'
2+
3+
describe Fastlane::Actions::OpenaiAskAction do
4+
let(:fake_token) { 'sk-proj-faketok' }
5+
let(:endpoint) { Fastlane::Actions::OpenaiAskAction::OPENAI_API_ENDPOINT }
6+
7+
def stubbed_response(text)
8+
<<~JSON
9+
{
10+
"id": "chatcmpl-Aa2NPY4sSWF5eKoW1aFBJmfc78y9p",
11+
"object": "chat.completion",
12+
"created": 1733152307,
13+
"model": "gpt-4o-2024-08-06",
14+
"choices": [
15+
{
16+
"index": 0,
17+
"message": {
18+
"role": "assistant",
19+
"content": #{text.to_json},
20+
"refusal": null
21+
},
22+
"logprobs": null,
23+
"finish_reason": "stop"
24+
}
25+
],
26+
"usage": {
27+
"prompt_tokens": 91,
28+
"completion_tokens": 68,
29+
"total_tokens": 159,
30+
"prompt_tokens_details": {
31+
"cached_tokens": 0,
32+
"audio_tokens": 0
33+
},
34+
"completion_tokens_details": {
35+
"reasoning_tokens": 0,
36+
"audio_tokens": 0,
37+
"accepted_prediction_tokens": 0,
38+
"rejected_prediction_tokens": 0
39+
}
40+
},
41+
"system_fingerprint": "fp_831e067d82"
42+
}
43+
JSON
44+
end
45+
46+
def run_test(prompt_param:, question_param:, expected_prompt:, expected_response:)
47+
expected_req_body = described_class.request_body(prompt: expected_prompt, question: question_param)
48+
49+
stub = stub_request(:post, endpoint)
50+
.with(body: expected_req_body)
51+
.to_return(status: 200, body: stubbed_response(expected_response))
52+
53+
result = run_described_fastlane_action(
54+
api_token: fake_token,
55+
prompt: prompt_param,
56+
question: question_param
57+
)
58+
59+
# Ensure the body of the request contains the expected JSON data
60+
messages = JSON.parse(expected_req_body)['messages']
61+
if expected_prompt.nil? || expected_prompt.empty?
62+
expect(messages.length).to eq(1)
63+
expect(messages[0]['role']).to eq('user')
64+
expect(messages[0]['content']).to eq(['type' => 'text', 'text' => question_param])
65+
else
66+
expect(messages.length).to eq(2)
67+
expect(messages[0]['role']).to eq('system')
68+
expect(messages[0]['content']).to eq(['type' => 'text', 'text' => expected_prompt])
69+
expect(messages[1]['role']).to eq('user')
70+
expect(messages[1]['content']).to eq(['type' => 'text', 'text' => question_param])
71+
end
72+
73+
# Ensure the request has been made and return the action response for it to be validated in calling test
74+
expect(stub).to have_been_requested
75+
result
76+
end
77+
78+
it 'calls the API with no prompt' do
79+
result = run_test(
80+
prompt_param: '',
81+
question_param: 'Say Hi',
82+
expected_prompt: nil,
83+
expected_response: 'Hello! How can I assist you today?'
84+
)
85+
86+
expect(result).to eq('Hello! How can I assist you today?')
87+
end
88+
89+
it 'calls the API with :release_notes prompt' do
90+
changelog = <<~CHANGELOG
91+
- [Internal] Fetch remote FF on site change [https://github.com/woocommerce/woocommerce-android/pull/12751]
92+
- [**] Improve barcode scanner reading accuracy [https://github.com/woocommerce/woocommerce-android/pull/12673]
93+
- [Internal] AI product creation banner is removed [https://github.com/woocommerce/woocommerce-android/pull/12705]
94+
- [*] [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]
95+
- [**] Fixed bug with coupons disappearing from the order creation screen unexpectedly [https://github.com/woocommerce/woocommerce-android/pull/12724]
96+
- [Internal] Fixes crash [https://github.com/woocommerce/woocommerce-android/issues/12715]
97+
- [*] Fixed incorrect instructions on "What is Tap to Pay" screen in the Payments section [https://github.com/woocommerce/woocommerce-android/pull/12709]
98+
- [***] Merchants can now view and edit custom fields of their products and orders from the app [https://github.com/woocommerce/woocommerce-android/issues/12207]
99+
- [*] Fix size of the whats new announcement dialog [https://github.com/woocommerce/woocommerce-android/pull/12692]
100+
- [*] Enables Blaze survey [https://github.com/woocommerce/woocommerce-android/pull/12761]
101+
CHANGELOG
102+
103+
expected_response = <<~TEXT
104+
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!
105+
TEXT
106+
107+
result = run_test(
108+
prompt_param: :release_notes,
109+
question_param: "Help me write release notes for the following items:\n#{changelog}",
110+
expected_prompt: Fastlane::Actions::OpenaiAskAction::PREDEFINED_PROMPTS[:release_notes],
111+
expected_response: expected_response
112+
)
113+
114+
expect(result).to eq(expected_response)
115+
end
116+
end

0 commit comments

Comments
 (0)