boxcars 0.2.0 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,21 +6,21 @@ module Boxcars
6
6
  class SQL < EngineBoxcar
7
7
  # the description of this engine boxcar
8
8
  SQLDESC = "useful for when you need to query a database for %<name>s."
9
+ LOCKED_OUT_TABLES = %w[schema_migrations ar_internal_metadata].freeze
9
10
  attr_accessor :connection
10
11
 
11
12
  # @param connection [ActiveRecord::Connection] The SQL connection to use for this boxcar.
12
- # @param engine [Boxcars::Engine] The engine to user for this boxcar. Can be inherited from a train if nil.
13
+ # @param tables [Array<String>] The tables to use for this boxcar. Will use all if nil.
14
+ # @param except_tables [Array<String>] The tables to exclude from this boxcar. Will exclude none if nil.
13
15
  # @param kwargs [Hash] Any other keyword arguments to pass to the parent class. This can include
14
- # :name, :description, :prompt and :top_k
15
- def initialize(connection: nil, engine: nil, **kwargs)
16
+ # :name, :description, :prompt, :top_k, :stop, and :engine
17
+ def initialize(connection: nil, tables: nil, except_tables: nil, **kwargs)
16
18
  @connection = connection || ::ActiveRecord::Base.connection
17
- the_prompt = kwargs[prompt] || my_prompt
18
- kwargs[:stop] ||= ["Answer:"]
19
- name = kwargs[:name] || "database"
20
- super(name: name,
21
- description: kwargs[:description] || format(SQLDESC, name: name),
22
- engine: engine,
23
- prompt: the_prompt)
19
+ check_tables(tables, except_tables)
20
+ kwargs[:name] ||= "Database"
21
+ kwargs[:description] ||= format(SQLDESC, name: name)
22
+ kwargs[:prompt] ||= my_prompt
23
+ super(**kwargs)
24
24
  end
25
25
 
26
26
  # @return Hash The additional variables for this boxcar.
@@ -30,6 +30,19 @@ module Boxcars
30
30
 
31
31
  private
32
32
 
33
+ def check_tables(rtables, exceptions)
34
+ if rtables.is_a?(Array) && tables.length.positive?
35
+ @requested_tables = rtables
36
+ all_tables = tables
37
+ rtables.each do |t|
38
+ raise ArgumentError, "table #{t} needs to be an Active Record model" unless all_tables.include?(t)
39
+ end
40
+ elsif rtables
41
+ raise ArgumentError, "tables needs to be an array of Strings"
42
+ end
43
+ @except_models = LOCKED_OUT_TABLES + exceptions.to_a
44
+ end
45
+
33
46
  def tables
34
47
  connection&.tables
35
48
  end
@@ -49,14 +62,22 @@ module Boxcars
49
62
  connection.class.name.split("::").last.sub("Adapter", "")
50
63
  end
51
64
 
52
- def get_embedded_sql_answer(text)
53
- code = text[/^SQLQuery: (.*)/, 1]
54
- Boxcars.debug code, :yellow
55
- output = connection.exec_query(code)
65
+ def clean_up_output(output)
66
+ output = output.as_json if output.is_a?(::ActiveRecord::Result)
56
67
  output = 0 if output.is_a?(Array) && output.empty?
57
68
  output = output.first if output.is_a?(Array) && output.length == 1
58
69
  output = output[output.keys.first] if output.is_a?(Hash) && output.length == 1
59
- "Answer: #{output.to_json}"
70
+ output = output.as_json if output.is_a?(::ActiveRecord::Relation)
71
+ output
72
+ end
73
+
74
+ def get_embedded_sql_answer(text)
75
+ code = text[/^SQLQuery: (.*)/, 1]
76
+ Boxcars.debug code, :yellow
77
+ output = clean_up_output(connection.exec_query(code))
78
+ Result.new(status: :ok, answer: output, explanation: "Answer: #{output.to_json}", code: code)
79
+ rescue StandardError => e
80
+ Result.new(status: :error, answer: nil, explanation: "Error: #{e.message}", code: code)
60
81
  end
61
82
 
62
83
  def get_answer(text)
@@ -64,43 +85,38 @@ module Boxcars
64
85
  when /^SQLQuery:/
65
86
  get_embedded_sql_answer(text)
66
87
  when /^Answer:/
67
- text
88
+ Result.from_text(text)
68
89
  else
69
- raise Boxcars::Error "Unknown format from engine: #{text}"
90
+ Result.from_error("Try answering again. Expected your answer to start with 'SQLQuery:'. You gave me:\n#{text}")
70
91
  end
71
92
  end
72
93
 
73
- TEMPLATE = <<~IPT
74
- Given an input question, first create a syntactically correct %<dialect>s query to run,
75
- then look at the results of the query and return the answer. Unless the user specifies
76
- in his question a specific number of examples he wishes to obtain, always limit your query
77
- to at most %<top_k>s results using a LIMIT clause. You can order the results by a relevant column
78
- to return the most interesting examples in the database.
79
-
80
- Never query for all the columns from a specific table, only ask for a the few relevant columns given the question.
81
-
82
- Pay attention to use only the column names that you can see in the schema description. Be careful to not query for columns that do not exist.
83
- Also, pay attention to which column is in which table.
84
-
85
- Use the following format:
86
- Question: "Question here"
87
- SQLQuery: "SQL Query to run"
88
- SQLResult: "Result of the SQLQuery"
89
- Answer: "Final answer here"
90
-
91
- Only use the following tables:
92
- %<schema>s
93
-
94
- Question: %<question>s
95
- IPT
94
+ CTEMPLATE = [
95
+ syst("Given an input question, first create a syntactically correct %<dialect>s SQL query to run, ",
96
+ "then look at the results of the query and return the answer. Unless the user specifies ",
97
+ "in her question a specific number of examples he wishes to obtain, always limit your query ",
98
+ "to at most %<top_k>s results using a LIMIT clause. You can order the results by a relevant column ",
99
+ "to return the most interesting examples in the database.\n",
100
+ "Never query for all the columns from a specific table, only ask for the elevant columns given the question.\n",
101
+ "Pay attention to use only the column names that you can see in the schema description. Be careful to ",
102
+ "not query for columns that do not exist. Also, pay attention to which column is in which table."),
103
+ syst("Use the following format:\n",
104
+ "Question: 'Question here'\n",
105
+ "SQLQuery: 'SQL Query to run'\n",
106
+ "SQLResult: 'Result of the SQLQuery'\n",
107
+ "Answer: 'Final answer here'"),
108
+ syst("Only use the following tables:\n%<schema>s"),
109
+ user("Question: %<question>s")
110
+ ].freeze
96
111
 
97
112
  # The prompt to use for the engine.
98
113
  def my_prompt
99
- @my_prompt ||= Prompt.new(
114
+ @conversation ||= Conversation.new(lines: CTEMPLATE)
115
+ @my_prompt ||= ConversationPrompt.new(
116
+ conversation: @conversation,
100
117
  input_variables: [:question],
101
118
  other_inputs: [:top_k, :dialect, :table_info],
102
- output_variables: [:answer],
103
- template: TEMPLATE)
119
+ output_variables: [:answer])
104
120
  end
105
121
  end
106
122
  end
@@ -62,16 +62,42 @@ module Boxcars
62
62
  # you can pass one or the other, but not both.
63
63
  # @return [String] The answer to the question.
64
64
  def run(*args, **kwargs)
65
+ rv = conduct(*args, **kwargs)
66
+ rv.is_a?(Result) ? rv.to_answer : rv
67
+ end
68
+
69
+ # Get an extended answer from the boxcar.
70
+ # @param args [Array] The positional arguments to pass to the boxcar.
71
+ # @param kwargs [Hash] The keyword arguments to pass to the boxcar.
72
+ # you can pass one or the other, but not both.
73
+ # @return [Boxcars::Result] The answer to the question.
74
+ def conduct(*args, **kwargs)
65
75
  Boxcars.info "> Entering #{name}#run", :gray, style: :bold
66
- rv = do_run(*args, **kwargs)
76
+ rv = depart(*args, **kwargs)
67
77
  Boxcars.info "< Exiting #{name}#run", :gray, style: :bold
68
78
  rv
69
79
  end
70
80
 
81
+ # helpers for conversation prompt building
82
+ # assistant message
83
+ def self.assi(*strs)
84
+ [:assistant, strs.join]
85
+ end
86
+
87
+ # system message
88
+ def self.syst(*strs)
89
+ [:system, strs.join]
90
+ end
91
+
92
+ # user message
93
+ def self.user(*strs)
94
+ [:user, strs.join]
95
+ end
96
+
71
97
  private
72
98
 
73
99
  # Get an answer from the boxcar.
74
- def do_call(inputs:, return_only_outputs: false)
100
+ def run_boxcar(inputs:, return_only_outputs: false)
75
101
  inputs = our_inputs(inputs)
76
102
  output = nil
77
103
  begin
@@ -81,19 +107,19 @@ module Boxcars
81
107
  raise e
82
108
  end
83
109
  validate_outputs(outputs: output.keys)
84
- # memory&.save_convext(inputs: inputs, outputs: outputs)
85
110
  return output if return_only_outputs
86
111
 
87
112
  inputs.merge(output)
88
113
  end
89
114
 
90
- def do_run(*args, **kwargs)
115
+ # line up parameters and run boxcar
116
+ def depart(*args, **kwargs)
91
117
  if kwargs.empty?
92
118
  raise Boxcars::ArgumentError, "run supports only one positional argument." if args.length != 1
93
119
 
94
- return do_call(inputs: args[0])[output_keys.first]
120
+ return run_boxcar(inputs: args[0])[output_keys.first]
95
121
  end
96
- return do_call(**kwargs)[output_keys].first if args.empty?
122
+ return run_boxcar(**kwargs)[output_keys].first if args.empty?
97
123
 
98
124
  raise Boxcars::ArgumentError, "run supported with either positional or keyword arguments but not both. Got args" \
99
125
  ": #{args} and kwargs: #{kwargs}."
@@ -114,11 +140,12 @@ module Boxcars
114
140
 
115
141
  # the default answer is the text passed in
116
142
  def get_answer(text)
117
- text
143
+ Result.from_text(text)
118
144
  end
119
145
  end
120
146
  end
121
147
 
148
+ require "boxcars/result"
122
149
  require "boxcars/boxcar/engine_boxcar"
123
150
  require "boxcars/boxcar/calculator"
124
151
  require "boxcars/boxcar/google_search"
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # used to keep track of the conversation
5
+ class Conversation
6
+ attr_reader :lines, :show_roles
7
+
8
+ PEOPLE = %i[system user assistant].freeze
9
+
10
+ def initialize(lines: [], show_roles: false)
11
+ @lines = lines
12
+ check_lines(@lines)
13
+ @show_roles = show_roles
14
+ end
15
+
16
+ # check the lines
17
+ def check_lines(lines)
18
+ raise ArgumentError, "Lines must be an array" unless lines.is_a?(Array)
19
+
20
+ lines.each do |ln|
21
+ raise ArgumentError, "Conversation item must be a array" unless ln.is_a?(Array)
22
+ raise ArgumentError, "Conversation item must have 2 items, role and text" unless ln.size == 2
23
+ raise ArgumentError, "Conversation item must have a role #{ln} in (#{PEOPLE})" unless PEOPLE.include? ln[0]
24
+ raise ArgumentError, "Conversation value must be a string" unless ln[1].is_a?(String)
25
+ end
26
+ end
27
+
28
+ # @return [Array] The result as a convesation array
29
+ def to_a
30
+ lines
31
+ end
32
+
33
+ # @return [String] A conversation string
34
+ def to_s
35
+ lines.map { |ln| "#{ln[0]}: #{ln[1]}" }.join("\n")
36
+ end
37
+
38
+ # add assistant text to the conversation at the end
39
+ # @param text [String] The text to add
40
+ def add_assistant(text)
41
+ @lines << [:assistant, text]
42
+ end
43
+
44
+ # add user text to the conversation at the end
45
+ # @param text [String] The text to add
46
+ def add_user(text)
47
+ @lines << [:user, text]
48
+ end
49
+
50
+ # add system text to the conversation at the end
51
+ # @param text [String] The text to add
52
+ def add_system(text)
53
+ @lines << [:system, text]
54
+ end
55
+
56
+ # add multiple lines to the conversation
57
+ def add_lines(lines)
58
+ check_lines(lines)
59
+ @lines += lines
60
+ end
61
+
62
+ # add a conversation to the conversation
63
+ def add_conversation(conversation)
64
+ @lines += conversation.lines
65
+ end
66
+
67
+ # return just the messages for the conversation
68
+ def message_text
69
+ lines.map(&:last).join("\n")
70
+ end
71
+
72
+ # compute the prompt parameters with input substitutions (used for chatGPT)
73
+ # @param inputs [Hash] The inputs to use for the prompt.
74
+ # @return [Hash] The formatted prompt { messages: ...}
75
+ def as_messages(inputs = nil)
76
+ { messages: lines.map { |ln| { role: ln.first, content: ln.last % inputs } } }
77
+ rescue ::KeyError => e
78
+ first_line = e.message.to_s.split("\n").first
79
+ Boxcars.error "Missing prompt input key: #{first_line}"
80
+ raise KeyError, "Prompt format error: #{first_line}"
81
+ end
82
+
83
+ # compute the prompt parameters with input substitutions
84
+ # @param inputs [Hash] The inputs to use for the prompt.
85
+ # @return [Hash] The formatted prompt { prompt: "..."}
86
+ def as_prompt(inputs = nil)
87
+ if show_roles
88
+ lines.map { |ln| format("#{ln.first}: #{ln.last}", inputs) }.join("\n\n")
89
+ else
90
+ lines.map { |ln| format(ln.last, inputs) }.join("\n\n")
91
+ end
92
+ rescue ::KeyError => e
93
+ first_line = e.message.to_s.split("\n").first
94
+ Boxcars.error "Missing prompt input key: #{first_line}"
95
+ raise KeyError, "Prompt format error: #{first_line}"
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # used by Boxcars that have engine's to create a conversation prompt.
5
+ class ConversationPrompt < Prompt
6
+ attr_reader :conversation
7
+
8
+ # @param conversation [Boxcars::Conversation] The conversation to use for the prompt.
9
+ # @param input_variables [Array<Symbol>] The input vars to use for the prompt. Defaults to [:input]
10
+ # @param other_inputs [Array<Symbol>] The other input vars to use for the prompt. Defaults to []
11
+ # @param output_variables [Array<Symbol>] The output vars to use for the prompt. Defaults to [:output]
12
+ def initialize(conversation:, input_variables: nil, other_inputs: nil, output_variables: nil)
13
+ @conversation = conversation
14
+ super(template: template, input_variables: input_variables, other_inputs: other_inputs, output_variables: output_variables)
15
+ end
16
+
17
+ # prompt for chatGPT params
18
+ # @param inputs [Hash] The inputs to use for the prompt.
19
+ # @return [Hash] The formatted prompt.
20
+ def as_messages(inputs)
21
+ conversation.as_messages(inputs)
22
+ end
23
+
24
+ # prompt for non chatGPT params
25
+ # @param inputs [Hash] The inputs to use for the prompt.
26
+ # @return [Hash] The formatted prompt.
27
+ def as_prompt(inputs)
28
+ { prompt: conversation.as_prompt(inputs) }
29
+ end
30
+
31
+ # tack on the ongoing conversation if present to the prompt
32
+ def with_conversation(conversation)
33
+ return self unless conversation
34
+
35
+ new_prompt = dup
36
+ new_prompt.conversation.add_conversation(conversation)
37
+ new_prompt
38
+ end
39
+ end
40
+ end
@@ -10,7 +10,7 @@ module Boxcars
10
10
  # The default parameters to use when asking the engine.
11
11
  DEFAULT_PARAMS = {
12
12
  model: "gpt-3.5-turbo",
13
- temperature: 0.7,
13
+ temperature: 0.2,
14
14
  max_tokens: 512
15
15
  }.freeze
16
16
 
@@ -38,17 +38,22 @@ module Boxcars
38
38
  # @param openai_access_token [String] The access token to use when asking the engine.
39
39
  # Defaults to Boxcars.configuration.openai_access_token.
40
40
  # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
41
- def client(prompt:, openai_access_token: nil, **kwargs)
41
+ def client(prompt:, inputs: {}, openai_access_token: nil, **kwargs)
42
42
  access_token = Boxcars.configuration.openai_access_token(openai_access_token: openai_access_token)
43
43
  organization_id = Boxcars.configuration.organization_id
44
44
  clnt = ::OpenAI::Client.new(access_token: access_token, organization_id: organization_id)
45
- the_params = { prompt: prompt }.merge(open_ai_params).merge(kwargs)
46
- if the_params[:model] == "gpt-3.5-turbo"
45
+ params = open_ai_params.merge(kwargs)
46
+ if params[:model] == "gpt-3.5-turbo"
47
47
  prompt = prompt.first if prompt.is_a?(Array)
48
- the_params = { messages: [{ role: "user", content: prompt }] }.merge(open_ai_params).merge(kwargs)
49
- clnt.chat(parameters: the_params)
48
+ params = prompt.as_messages(inputs).merge(params)
49
+ if Boxcars.configuration.log_prompts
50
+ Boxcars.debug(params[:messages].map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
51
+ end
52
+ clnt.chat(parameters: params)
50
53
  else
51
- clnt.completions(parameters: the_params)
54
+ params = prompt.as_prompt(inputs).merge(params)
55
+ Boxcars.debug("Prompt after formatting:\n#{params[:prompt]}", :cyan) if Boxcars.configuration.log_prompts
56
+ clnt.completions(parameters: params)
52
57
  end
53
58
  end
54
59
 
@@ -56,7 +61,8 @@ module Boxcars
56
61
  # @param question [String] The question to ask the engine.
57
62
  # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
58
63
  def run(question, **kwargs)
59
- response = client(prompt: question, **kwargs)
64
+ prompt = Prompt.new(template: question)
65
+ response = client(prompt: prompt, **kwargs)
60
66
  answer = response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
61
67
  puts answer
62
68
  answer
@@ -110,6 +116,7 @@ module Boxcars
110
116
 
111
117
  # Call out to OpenAI's endpoint with k unique prompts.
112
118
  # @param prompts [Array<String>] The prompts to pass into the model.
119
+ # @param inputs [Array<String>] The inputs to subsitite into the prompt.
113
120
  # @param stop [Array<String>] Optional list of stop words to use when generating.
114
121
  # @return [EngineResult] The full engine output.
115
122
  def generate(prompts:, stop: nil)
@@ -120,13 +127,14 @@ module Boxcars
120
127
  # Get the token usage from the response.
121
128
  # Includes prompt, completion, and total tokens used.
122
129
  inkeys = %w[completion_tokens prompt_tokens total_tokens].freeze
123
- sub_prompts = prompts.each_slice(batch_size).to_a
124
- sub_prompts.each do |sprompts|
125
- response = client(prompt: sprompts, **params)
126
- check_response(response)
127
- choices.concat(response["choices"])
128
- keys_to_use = inkeys & response["usage"].keys
129
- keys_to_use.each { |key| token_usage[key] = token_usage[key].to_i + response["usage"][key] }
130
+ prompts.each_slice(batch_size) do |sub_prompts|
131
+ sub_prompts.each do |sprompts, inputs|
132
+ response = client(prompt: sprompts, inputs: inputs, **params)
133
+ check_response(response)
134
+ choices.concat(response["choices"])
135
+ usage_keys = inkeys & response["usage"].keys
136
+ usage_keys.each { |key| token_usage[key] = token_usage[key].to_i + response["usage"][key] }
137
+ end
130
138
  end
131
139
 
132
140
  n = params.fetch(:n, 1)
@@ -16,6 +16,31 @@ module Boxcars
16
16
  @output_variables = output_variables || [:output]
17
17
  end
18
18
 
19
+ # compute the prompt parameters with input substitutions (used for chatGPT)
20
+ # @param inputs [Hash] The inputs to use for the prompt.
21
+ # @return [Hash] The formatted prompt { messages: ...}
22
+ def as_prompt(inputs)
23
+ { prompt: format(inputs) }
24
+ end
25
+
26
+ # compute the prompt parameters with input substitutions
27
+ # @param inputs [Hash] The inputs to use for the prompt.
28
+ # @return [Hash] The formatted prompt { prompt: "..."}
29
+ def as_messages(inputs)
30
+ { messages: [{ role: :assistant, content: format(inputs) }] }
31
+ end
32
+
33
+ # tack on the ongoing conversation if present to the prompt
34
+ def with_conversation(conversation)
35
+ return self unless conversation
36
+
37
+ new_prompt = dup
38
+ new_prompt.template += "\n\n#{conversation.message_text}"
39
+ new_prompt
40
+ end
41
+
42
+ private
43
+
19
44
  # format the prompt with the input variables
20
45
  # @param inputs [Hash] The inputs to use for the prompt.
21
46
  # @return [String] The formatted prompt.
@@ -27,43 +52,5 @@ module Boxcars
27
52
  Boxcars.error "Missing prompt input key: #{first_line}"
28
53
  raise KeyError, "Prompt format error: #{first_line}"
29
54
  end
30
-
31
- # check if the template is valid
32
- def template_is_valid?
33
- all_vars = (input_variables + other_inputs + output_variables).sort
34
- template_vars = @template.scan(/%<(\w+)>s/).flatten.map(&:to_sym).sort
35
- all_vars == template_vars
36
- end
37
-
38
- # missing variables in the template
39
- def missing_variables?(inputs)
40
- input_vars = [input_variables, other_inputs].flatten.sort
41
- return if inputs.keys.sort == input_vars
42
-
43
- raise ArgumentError, "Missing expected input keys, got: #{inputs.keys}. Expected: #{input_vars}"
44
- end
45
-
46
- # create a prompt template from examples
47
- # @param examples [String] or [Array<String>] The example(s) to use for the template.
48
- # @param input_variables [Array<Symbol>] The input variables to use for the prompt.
49
- # @param example_separator [String] The separator to use between the examples. Defaults to "\n\n"
50
- # @param prefix [String] The prefix to use for the template. Defaults to ""
51
- def self.from_examples(examples:, suffix:, input_variables:, example_separator: "\n\n", prefix: "", **kwargs)
52
- template = [prefix, examples, suffix].join(example_separator)
53
- other_inputs = kwargs[:other_inputs] || []
54
- output_variables = kwargs[:output_variables] || [:output]
55
- Prompt.new(template: template, input_variables: input_variables, other_inputs: other_inputs,
56
- output_variables: output_variables)
57
- end
58
-
59
- # create a prompt template from a file
60
- # @param path [String] The path to the file to use for the template.
61
- # @param input_variables [Array<Symbol>] The input variables to use for the prompt. Defaults to [:input]
62
- # @param output_variables [Array<Symbol>] The output variables to use for the prompt. Defaults to [:output]
63
- def self.from_file(path:, input_variables: nil, other_inputs: nil, output_variables: nil)
64
- template = File.read(path)
65
- Prompt.new(template: template, input_variables: input_variables, other_inputs: other_inputs,
66
- output_variables: output_variables)
67
- end
68
55
  end
69
56
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # used by Boxcars to return structured result and additional context
5
+ class Result
6
+ attr_reader :status, :answer, :explanation, :suggestions, :added_context
7
+
8
+ # @param status [Symbol] :ok or :error
9
+ # @param answer [String] The answer to the question
10
+ # @param explanation [String] The explanation of the answer
11
+ # @param suggestions [Array<String>] The next suggestions for the user
12
+ # @param added_context [Hash] Any additional context to add to the result
13
+ def initialize(status:, answer: nil, explanation: nil, suggestions: nil, **added_context)
14
+ @status = status
15
+ @answer = answer || explanation
16
+ @explanation = explanation
17
+ @suggestions = suggestions
18
+ @added_context = added_context
19
+ end
20
+
21
+ # @return [Hash] The result as a hash
22
+ def to_h
23
+ {
24
+ status: status,
25
+ answer: answer,
26
+ explanation: explanation,
27
+ suggestions: suggestions
28
+ }.merge(added_context).compact
29
+ end
30
+
31
+ # @return [String] The result as a json string
32
+ def to_json(*args)
33
+ JSON.generate(to_h, *args)
34
+ end
35
+
36
+ # @return [String] An explanation of the result
37
+ def to_s
38
+ explanation
39
+ end
40
+
41
+ # @return [String] The answer data to the question
42
+ def to_answer
43
+ answer
44
+ end
45
+
46
+ # create a new Result from a text string
47
+ # @param text [String] The text to use for the result
48
+ # @param kwargs [Hash] Any additional kwargs to pass to the result
49
+ # @return [Boxcars::Result] The result
50
+ def self.from_text(text, **kwargs)
51
+ answer = text.delete_prefix('"').delete_suffix('"').strip
52
+ answer = Regexp.last_match(:answer) if answer =~ /^Answer:\s*(?<answer>.*)$/
53
+ explanation = "Answer: #{answer}"
54
+ new(status: :ok, answer: answer, explanation: explanation, **kwargs)
55
+ end
56
+
57
+ # create a new Result from an error string
58
+ # @param error [String] The error to use for the result
59
+ # @param kwargs [Hash] Any additional kwargs to pass to the result
60
+ # @return [Boxcars::Result] The error result
61
+ def self.from_error(error, **kwargs)
62
+ answer = error
63
+ answer = Regexp.last_match(:answer) if answer =~ /^Error:\s*(?<answer>.*)$/
64
+ explanation = "Error: #{answer}"
65
+ new(status: :error, answer: answer, explanation: explanation, **kwargs)
66
+ end
67
+ end
68
+ end
@@ -9,19 +9,20 @@ module Boxcars
9
9
  Boxcars.debug "RubyREPL: #{code}", :yellow
10
10
 
11
11
  # wrap the code in an excption block so we can catch errors
12
- code = "begin\n#{code}\nrescue Exception => e\n puts 'Error: ' + e.message\nend"
12
+ wrapped = "begin\n#{code}\nrescue Exception => e\n puts 'Error: ' + e.message\nend"
13
13
  output = ""
14
14
  IO.popen("ruby", "r+") do |io|
15
- io.puts code
15
+ io.puts wrapped
16
16
  io.close_write
17
17
  output = io.read
18
18
  end
19
19
  if output =~ /^Error: /
20
- Boxcars.error output
21
- output
20
+ Boxcars.debug output, :red
21
+ Result.from_error(output, code: code)
22
+ else
23
+ Boxcars.debug "Answer: #{output}", :yellow, style: :bold
24
+ Result.from_text(output, code: code)
22
25
  end
23
- Boxcars.debug "Answer: #{output}", :yellow, style: :bold
24
- output
25
26
  end
26
27
 
27
28
  # Execute ruby code
@@ -5,10 +5,24 @@ module Boxcars
5
5
  class TrainAction
6
6
  attr_accessor :boxcar, :boxcar_input, :log
7
7
 
8
- def initialize(boxcar: nil, boxcar_input: nil, log: nil)
9
- @boxcar = boxcar
8
+ # record for a train action
9
+ # @param boxcar [String] The boxcar to run.
10
+ # @param log [String] The log of the action.
11
+ # @param boxcar_input [String] The input to the boxcar.
12
+ # @return [Boxcars::TrainAction] The train action.
13
+ def initialize(boxcar:, log:, boxcar_input: nil)
10
14
  @boxcar_input = boxcar_input
15
+ @boxcar = boxcar
11
16
  @log = log
12
17
  end
18
+
19
+ # build a train action from a result
20
+ # @param result [Boxcars::Result] The result to build from.
21
+ # @param boxcar [String] The boxcar to run.
22
+ # @param log [String] The log of the action.
23
+ # @return [Boxcars::TrainAction] The train action.
24
+ def self.from_result(result:, boxcar:, log:)
25
+ new(boxcar: boxcar, boxcar_input: result.to_answer, log: log)
26
+ end
13
27
  end
14
28
  end