boxcars 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Boxcars is a framework for running a series of tools to get an answer to a question.
4
+ module Boxcars
5
+ # For Boxcars that use an engine to do their work.
6
+ # @abstract
7
+ class EngineBoxcar < Boxcars::Boxcar
8
+ attr_accessor :prompt, :engine, :output_key
9
+
10
+ # A Boxcar is a container for a single tool to run.
11
+ # @param prompt [Boxcars::Prompt] The prompt to use for this boxcar with sane defaults.
12
+ # @param name [String] The name of the boxcar. Defaults to classname.
13
+ # @param description [String] A description of the boxcar.
14
+ # @param engine [Boxcars::Engine] The engine to user for this boxcar. Can be inherited from a conductor if nil.
15
+ def initialize(prompt:, engine:, output_key: "text", name: nil, description: nil)
16
+ @prompt = prompt
17
+ @engine = engine
18
+ @output_key = output_key
19
+ super(name: name, description: description)
20
+ end
21
+
22
+ def input_keys
23
+ prompt.input_variables
24
+ end
25
+
26
+ def output_keys
27
+ [output_key]
28
+ end
29
+
30
+ # # Check that all inputs are present.
31
+ # def validate_inputs(inputs:)
32
+ # missing_keys = input_keys - inputs.keys
33
+ # raise Boxcars::ArgumentError, "Missing some input keys: #{missing_keys}" if missing_keys.any?
34
+
35
+ # inputs
36
+ # end
37
+
38
+ # def validate_outputs(outputs:)
39
+ # return if outputs.sort == output_keys.sort
40
+
41
+ # raise Boxcars::ArgumentError, "Did not get out keys that were expected, got: #{outputs}. Expected: #{output_keys}"
42
+ # end
43
+
44
+ def generate(input_list:)
45
+ stop = input_list[0][:stop]
46
+ prompts = []
47
+ input_list.each do |inputs|
48
+ new_prompt = prompt.format(**inputs)
49
+ # puts "Prompt after formatting:\n#{new_prompt.colorize(:cyan)}"
50
+ prompts.push(new_prompt)
51
+ end
52
+ engine.generate(prompts: prompts, stop: stop)
53
+ end
54
+
55
+ def apply(input_list:)
56
+ response = generate(input_list: input_list)
57
+ response.generations.to_h do |generation|
58
+ [output_key, generation[0].text]
59
+ end
60
+ end
61
+
62
+ def predict(**kwargs)
63
+ apply(input_list: [kwargs])[output_key]
64
+ end
65
+
66
+ def predict_and_parse(**kwargs)
67
+ result = predict(**kwargs)
68
+ if prompt.output_parser
69
+ prompt.output_parser.parse(result)
70
+ else
71
+ result
72
+ end
73
+ end
74
+
75
+ def apply_and_parse(input_list:)
76
+ result = apply(input_list: input_list)
77
+ if prompt.output_parser
78
+ result.map { |r| prompt.output_parser.parse(r[output_key]) }
79
+ else
80
+ result
81
+ end
82
+ end
83
+
84
+ def check_output_keys
85
+ return unless output_keys.length != 1
86
+
87
+ raise Boxcars::ArgumentError, "run not supported when there is not exactly one output key. Got #{output_keys}."
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google_search_results'
4
+ module Boxcars
5
+ # A Boxcar that uses the Google SerpAPI to get answers to questions.
6
+ class Serp < Boxcar
7
+ SERPDESC = "useful for when you need to answer questions about current events." \
8
+ "You should ask targeted questions"
9
+
10
+ # implements a boxcar that uses the Google SerpAPI to get answers to questions.
11
+ # @param name [String] The name of the boxcar. Defaults to classname.
12
+ # @param description [String] A description of the boxcar. Defaults to SERPDESC.
13
+ # @param engine [Boxcars::Engine] The engine to user for this boxcar. Can be inherited from a Conductor if nil.
14
+ #
15
+ def initialize(name: "Search", description: SERPDESC, serpapi_api_key: "not set")
16
+ super(name: name, description: description)
17
+ api_key = Boxcars.configuration.serpapi_api_key(serpapi_api_key: serpapi_api_key)
18
+ GoogleSearch.api_key = api_key
19
+ end
20
+
21
+ # Get an answer from Google using the SerpAPI.
22
+ # @param question [String] The question to ask Google.
23
+ # @return [String] The answer to the question.
24
+ def run(question)
25
+ search = GoogleSearch.new(q: question)
26
+ rv = find_answer(search.get_hash)
27
+ puts "Question: #{question}"
28
+ puts "Answer: #{rv}"
29
+ rv
30
+ end
31
+
32
+ # Get the location of an answer from Google using the SerpAPI.
33
+ # @param question [String] The question to ask Google.
34
+ # @return [String] The location found.
35
+ def get_location(question)
36
+ search = GoogleSearch.new(q: question, limit: 3)
37
+ rv = search.get_location
38
+ puts "Question: #{question}"
39
+ puts "Answer: #{rv}"
40
+ rv
41
+ end
42
+
43
+ private
44
+
45
+ ANSWER_LOCATIONS = [
46
+ %i[answer_box answer],
47
+ %i[answer_box snippet],
48
+ [:answer_box, :snippet_highlighted_words, 0],
49
+ %i[sports_results game_spotlight],
50
+ %i[knowledge_graph description],
51
+ [:organic_results, 0, :snippet_highlighted_words, 0],
52
+ [:organic_results, 0, :snippet]
53
+ ].freeze
54
+
55
+ def find_answer(res)
56
+ raise Error, "Got error from SerpAPI: {res[:error]}" if res[:error]
57
+
58
+ ANSWER_LOCATIONS.each do |path|
59
+ return res.dig(*path) if res.dig(*path)
60
+ end
61
+ "No good search result found"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Boxcars is a framework for running a series of tools to get an answer to a question.
4
+ module Boxcars
5
+ # A Boxcar that interprets a prompt and executes SQL code to get answers
6
+ class SQL < EngineBoxcar
7
+ SQLDESC = "useful for when you need to query a SQL database"
8
+ attr_accessor :connection, :input_key
9
+
10
+ # @param connection [ActiveRecord::Connection] The SQL connection to use for this boxcar.
11
+ # @param prompt [Boxcars::Prompt] The prompt to use for this boxcar.
12
+ # @param name [String] The name of the boxcar. Defaults to classname.
13
+ # @param description [String] A description of the boxcar.
14
+ # @param engine [Boxcars::Engine] The engine to user for this boxcar. Can be inherited from a conductor if nil.
15
+ # @param input_key [Symbol] The key to use for the input. Defaults to :question.
16
+ # @param output_key [Symbol] The key to use for the output. Defaults to :answer.
17
+ def initialize(connection:, engine: nil, input_key: :question, output_key: :answer, **kwargs)
18
+ @connection = connection
19
+ @input_key = input_key
20
+ the_prompt = kwargs[prompt] || my_prompt
21
+ super(name: kwargs[:name] || "SQLdatabase",
22
+ description: kwargs[:description] || SQLDESC,
23
+ engine: engine,
24
+ prompt: the_prompt,
25
+ output_key: output_key)
26
+ end
27
+
28
+ def input_keys
29
+ [input_key]
30
+ end
31
+
32
+ def output_keys
33
+ [output_key]
34
+ end
35
+
36
+ def call(inputs:)
37
+ t = predict(question: inputs[input_key], dialect: dialect, top_k: 5, table_info: schema, stop: ["SQLQuery:"]).strip
38
+ answer = get_answer(t)
39
+ puts answer.colorize(:magenta)
40
+ { output_key => answer }
41
+ end
42
+
43
+ private
44
+
45
+ def tables
46
+ connection&.tables
47
+ end
48
+
49
+ def table_schema(table)
50
+ ["CREATE TABLE #{table} (",
51
+ connection&.columns(table)&.map { |c| " #{c.name} #{c.sql_type} #{c.null ? "NULL" : "NOT NULL"}" }&.join(",\n"),
52
+ ");"].join("\n")
53
+ end
54
+
55
+ def schema(except_tables: ['ar_internal_metadata'])
56
+ wanted_tables = tables.to_a - except_tables
57
+ wanted_tables.map(&method(:table_schema)).join("\n")
58
+ end
59
+
60
+ def dialect
61
+ # connection.instance_variable_get "@config"[:adapter]
62
+ connection.class.name.split("::").last.sub("Adapter", "")
63
+ end
64
+
65
+ def get_embedded_sql_answer(text)
66
+ code = text[/^SQLQuery: (.*)/, 1]
67
+ puts code.colorize(:yellow)
68
+ output = connection.exec_query(code).to_a
69
+ puts "Answer: #{output}"
70
+ "Answer: #{output}"
71
+ end
72
+
73
+ def get_answer(text)
74
+ case text
75
+ when /^SQLQuery:/
76
+ get_embedded_sql_answer(text)
77
+ when /^Answer:/
78
+ text
79
+ else
80
+ raise Boxcars::Error "Unknown format from engine: #{text}"
81
+ end
82
+ end
83
+
84
+ TEMPLATE = <<~IPT
85
+ Given an input question, first create a syntactically correct %<dialect>s query to run,
86
+ then look at the results of the query and return the answer. Unless the user specifies
87
+ in his question a specific number of examples he wishes to obtain, always limit your query
88
+ to at most %<top_k>s results using a LIMIT clause. You can order the results by a relevant column
89
+ to return the most interesting examples in the database.
90
+
91
+ Never query for all the columns from a specific table, only ask for a the few relevant columns given the question.
92
+
93
+ 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.
94
+ Also, pay attention to which column is in which table.
95
+
96
+ Use the following format:
97
+ Question: "Question here"
98
+ SQLQuery: "SQL Query to run"
99
+ SQLResult: "Result of the SQLQuery"
100
+ Answer: "Final answer here"
101
+
102
+ Only use the following tables:
103
+ %<table_info>s
104
+
105
+ Question: %<question>s
106
+ IPT
107
+
108
+ # The prompt to use for the engine.
109
+ def my_prompt
110
+ @my_prompt ||= Prompt.new(input_variables: [:question, :dialect, :top_k], template: TEMPLATE)
111
+ end
112
+
113
+ # DECIDER_TEMPLATE = <<~DPT
114
+ # Given the below input question and list of potential tables, output a comma separated list of the table names that may
115
+ # be necessary to answer this question.
116
+ # Question: %<query>s
117
+ # Table Names: %<table_names>s
118
+ # Relevant Table Names:
119
+ # DPT
120
+ # DECIDER_PROMPT = Prompt.new(
121
+ # input_variables: %i[query table_names],
122
+ # template: DECIDER_TEMPLATE,
123
+ # output_parser: CommaSeparatedListOutputParser
124
+ # )
125
+ end
126
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # @abstract
5
+ class Boxcar
6
+ attr_reader :name, :description, :return_direct
7
+
8
+ # A Boxcar is a container for a single tool to run.
9
+ # @param name [String] The name of the boxcar. Defaults to classname.
10
+ # @param description [String] A description of the boxcar.
11
+ # @param return_direct [Boolean] If true, return the output of this boxcar directly, without merging it with the inputs.
12
+ def initialize(description:, name: nil, return_direct: false)
13
+ @name = name || self.class.name
14
+ @description = description
15
+ @return_direct = return_direct
16
+ end
17
+
18
+ # Input keys this chain expects.
19
+ def input_keys
20
+ raise NotImplementedError
21
+ end
22
+
23
+ # Output keys this chain expects.
24
+ def output_keys
25
+ raise NotImplementedError
26
+ end
27
+
28
+ # Check that all inputs are present.
29
+ def validate_inputs(inputs:)
30
+ missing_keys = input_keys - inputs.keys
31
+ raise "Missing some input keys: #{missing_keys}" if missing_keys.any?
32
+
33
+ inputs
34
+ end
35
+
36
+ def validate_outputs(outputs:)
37
+ return if outputs.sort == output_keys.sort
38
+
39
+ raise "Did not get output keys that were expected, got: #{outputs}. Expected: #{output_keys}"
40
+ end
41
+
42
+ # Run the logic of this chain and return the output.
43
+ def call(inputs:)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ def do_call(inputs:, return_only_outputs: false)
48
+ inputs = our_inputs(inputs)
49
+ output = nil
50
+ begin
51
+ output = call(inputs: inputs)
52
+ rescue StandardError => e
53
+ raise e
54
+ end
55
+ validate_outputs(outputs: output.keys)
56
+ # memory&.save_convext(inputs: inputs, outputs: outputs)
57
+ return output if return_only_outputs
58
+
59
+ inputs.merge(output)
60
+ end
61
+
62
+ def apply(input_list:)
63
+ input_list.map { |inputs| new(**inputs) }
64
+ end
65
+
66
+ # Get an answer from the boxcar.
67
+ # @param question [String] The question to ask the boxcar.
68
+ # @return [String] The answer to the question.
69
+ def run(*args, **kwargs)
70
+ puts "> Enterning #{name} boxcar#run".colorize(:gray, style: :bold)
71
+ rv = do_run(*args, **kwargs)
72
+ puts "< Exiting #{name} boxcar#run".colorize(:gray, style: :bold)
73
+ rv
74
+ end
75
+
76
+ private
77
+
78
+ def do_run(*args, **kwargs)
79
+ if kwargs.empty?
80
+ raise Boxcars::ArgumentError, "run supports only one positional argument." if args.length != 1
81
+
82
+ return do_call(inputs: args[0])[output_keys.first]
83
+ end
84
+ return do_call(**kwargs)[output_keys].first if args.empty?
85
+
86
+ raise Boxcars::ArgumentError, "run supported with either positional or keyword arguments but not both. Got args" \
87
+ ": #{args} and kwargs: #{kwargs}."
88
+ end
89
+
90
+ def our_inputs(inputs)
91
+ if inputs.is_a?(String)
92
+ puts inputs.colorize(:blue) # the question
93
+ if input_keys.length != 1
94
+ raise Boxcars::ArgumentError, "A single string input was passed in, but this boxcar expects " \
95
+ "multiple inputs (#{input_keys}). When a boxcar expects " \
96
+ "multiple inputs, please call it by passing in a hash, eg: `boxcar({'foo': 1, 'bar': 2})`"
97
+ end
98
+ inputs = { input_keys.first => inputs }
99
+ end
100
+ validate_inputs(inputs: inputs)
101
+ end
102
+ end
103
+ end
104
+
105
+ require "boxcars/boxcar/engine_boxcar"
106
+ require "boxcars/boxcar/calculator"
107
+ require "boxcars/boxcar/serp"
108
+ require "boxcars/boxcar/sql"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # Conductor's action to take.
5
+ class ConductorAction
6
+ attr_accessor :boxcar, :boxcar_input, :log
7
+
8
+ def initialize(boxcar: nil, boxcar_input: nil, log: nil)
9
+ @boxcar = boxcar
10
+ @boxcar_input = boxcar_input
11
+ @log = log
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # Consists of an conductor using boxcars.
5
+ class ConductorExecuter < EngineBoxcar
6
+ attr_accessor :conductor, :boxcars, :return_intermediate_steps, :max_iterations, :early_stopping_method
7
+
8
+ # @param conductor [Boxcars::Conductor] The conductor to use.
9
+ # @param boxcars [Array<Boxcars::Boxcar>] The boxcars to use.
10
+ # @param return_intermediate_steps [Boolean] Whether to return the intermediate steps. Defaults to false.
11
+ # @param max_iterations [Integer] The maximum number of iterations to run. Defaults to nil.
12
+ # @param early_stopping_method [String] The early stopping method to use. Defaults to "force".
13
+ def initialize(conductor:, boxcars:, return_intermediate_steps: false, max_iterations: nil,
14
+ early_stopping_method: "force")
15
+ @conductor = conductor
16
+ @boxcars = boxcars
17
+ @return_intermediate_steps = return_intermediate_steps
18
+ @max_iterations = max_iterations
19
+ @early_stopping_method = early_stopping_method
20
+ # def initialize(prompt:, engine:, output_key: "text", name: nil, description: nil)
21
+ super(prompt: conductor.prompt, engine: conductor.engine, name: conductor.name, description: conductor.description)
22
+ end
23
+
24
+ def same_boxcars?(boxcar_names)
25
+ conductor.allowed_boxcars.sort == boxcar_names
26
+ end
27
+
28
+ def validate_boxcars
29
+ boxcar_names = boxcars.map(&:name).sort
30
+ return if same_boxcars?(boxcar_names)
31
+
32
+ raise "Allowed boxcars (#{conductor.allowed_boxcars}) different than provided boxcars (#{boxcar_names})"
33
+ end
34
+
35
+ def input_keys
36
+ conductor.input_keys
37
+ end
38
+
39
+ def output_keys
40
+ return conductor.return_values + ["intermediate_steps"] if return_intermediate_steps
41
+
42
+ conductor.return_values
43
+ end
44
+
45
+ def should_continue?(iterations)
46
+ return true if max_iterations.nil?
47
+
48
+ iterations < max_iterations
49
+ end
50
+
51
+ # handler before returning
52
+ def pre_return(output, intermediate_steps)
53
+ puts output.log.colorize(:yellow)
54
+ final_output = output.return_values
55
+ final_output["intermediate_steps"] = intermediate_steps if return_intermediate_steps
56
+ final_output
57
+ end
58
+
59
+ def engine_prefix(return_direct)
60
+ return_direct ? "" : conductor.engine_prefix
61
+ end
62
+
63
+ def call(inputs:)
64
+ conductor.prepare_for_new_call
65
+ name_to_boxcar_map = boxcars.to_h { |boxcar| [boxcar.name, boxcar] }
66
+ intermediate_steps = []
67
+ iterations = 0
68
+ while should_continue?(iterations)
69
+ output = conductor.plan(intermediate_steps, **inputs)
70
+ return pre_return(output, intermediate_steps) if output.is_a?(ConductorFinish)
71
+
72
+ if (boxcar = name_to_boxcar_map[output.boxcar])
73
+ begin
74
+ observation = boxcar.run(output.boxcar_input)
75
+ return_direct = boxcar.return_direct
76
+ rescue StandardError => e
77
+ raise e
78
+ end
79
+ else
80
+ observation = "#{output.boxcar} is not a valid boxcar, try another one."
81
+ return_direct = false
82
+ end
83
+ puts "#Observation: #{observation}".colorize(:green)
84
+ intermediate_steps.append([output, observation])
85
+ if return_direct
86
+ output = ConductorFinish.new({ conductor.return_values[0] => observation }, "")
87
+ return pre_return(output, intermediate_steps)
88
+ end
89
+ iterations += 1
90
+ end
91
+ output = conductor.return_stopped_response(early_stopping_method, intermediate_steps, **inputs)
92
+ pre_return(output, intermediate_steps)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # Conductor's return value
5
+ class ConductorFinish
6
+ attr_accessor :return_values, :log
7
+
8
+ def initialize(return_values, log:)
9
+ @return_values = return_values
10
+ @log = log
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,81 @@
1
+ # Agent for the MRKL chain
2
+ module Boxcars
3
+ # A Conductor using the zero-shot react method.
4
+ class ZeroShot < Conductor
5
+ attr_reader :boxcars, :observation_prefix, :engine_prefix
6
+
7
+ PREFIX = "Answer the following questions as best you can. You have access to the following actions:".freeze
8
+ FORMAT_INSTRUCTIONS = <<~FINPUT.freeze
9
+ Use the following format:
10
+
11
+ Question: the input question you must answer
12
+ Thought: you should always think about what to do
13
+ Action: the action to take, should be one of [%<boxcar_names>s]
14
+ Action Input: the input to the action
15
+ Observation: the result of the action
16
+ ... (this Thought/Action/Action Input/Observation sequence can repeat N times)
17
+ Thought: I now know the final answer
18
+ Final Answer: the final answer to the original input question
19
+ FINPUT
20
+
21
+ SUFFIX = <<~SINPUT.freeze
22
+ Begin!
23
+
24
+ Question: %<input>s
25
+ Thought:%<agent_scratchpad>s
26
+ SINPUT
27
+
28
+ def initialize(boxcars:, engine:, name: 'Zero Shot', description: 'Zero Shot Conductor')
29
+ @observation_prefix = 'Observation: '
30
+ @engine_prefix = 'Thought:'
31
+ prompt = self.class.create_prompt(boxcars: boxcars)
32
+ super(engine: engine, boxcars: boxcars, prompt: prompt, name: name, description: description)
33
+ end
34
+
35
+ # Create prompt in the style of the zero shot agent.
36
+
37
+ # Args:
38
+ # boxcars: List of boxcars the agent will have access to, used to format the prompt.
39
+ # prefix: String to put before the list of boxcars.
40
+ # suffix: String to put after the list of boxcars.
41
+ # input_variables: List of input variables the final prompt will expect.
42
+
43
+ # Returns:
44
+ # A Prompt with the template assembled from the pieces here.
45
+
46
+ def self.create_prompt(boxcars:, prefix: PREFIX, suffix: SUFFIX, input_variables: [:input, :agent_scratchpad])
47
+ boxcar_strings = boxcars.map { |boxcar| "#{boxcar.name}: #{boxcar.description}" }.join("\n")
48
+ boxcar_names = boxcars.map(&:name)
49
+ format_instructions = format(FORMAT_INSTRUCTIONS, boxcar_names: boxcar_names.join(", "))
50
+ template = [prefix, boxcar_strings, format_instructions, suffix].join("\n\n")
51
+ Prompt.new(template: template, input_variables: input_variables)
52
+ end
53
+
54
+ FINAL_ANSWER_ACTION = "Final Answer:".freeze
55
+
56
+ # Parse out the action and input from the engine output.
57
+ def get_action_and_input(engine_output:)
58
+ # NOTE: if you're specifying a custom prompt for the ZeroShotAgent,
59
+ # you will need to ensure that it meets the following Regex requirements.
60
+ # The string starting with "Action:" and the following string starting
61
+ # with "Action Input:" should be separated by a newline.
62
+ if engine_output.include?(FINAL_ANSWER_ACTION)
63
+ answer = engine_output.split(FINAL_ANSWER_ACTION).last.strip
64
+ ['Final Answer', answer]
65
+ else
66
+ regex = /Action: (?<action>.*)\nAction Input: (?<action_input>.*)/
67
+ match = regex.match(engine_output)
68
+ raise ValueError, "Could not parse engine output: #{engine_output}" unless match
69
+
70
+ action = match[:action].strip
71
+ action_input = match[:action_input].strip
72
+ # [action, action_input.strip(" ").strip('"')]
73
+ [action, action_input]
74
+ end
75
+ end
76
+
77
+ def extract_boxcar_and_input(text)
78
+ get_action_and_input(engine_output: text)
79
+ end
80
+ end
81
+ end