boxcars 0.2.16 → 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92af5ad71c16886afc760bcc1b98a1d050486f8cf7010e0f05ee5015aa767710
4
- data.tar.gz: a0e5f39d40f79d4e4e5e337c00657764b5f9b329ec6bcc0f5a6e4ea62b0675b5
3
+ metadata.gz: 463f57f1436cfea29e0a60bbfce071afd355a5d0cadbe1d69f96adc7393eacd6
4
+ data.tar.gz: 282e528fb8cb8b532b621db5c2e7ce2b82c4607057c01f0feb1eaacfe98ba09b
5
5
  SHA512:
6
- metadata.gz: 06123e9f6ea4cb294b258056b6e50af7654b2c4866b1585fb32e520eee4e64dcdecc7cf8dc4d019a42a3193b01be6a02fba8190ef52add4eb70ad7f1a79d492c
7
- data.tar.gz: 3320ee8fdb68a4bf9601a6a584d64517242e0e80b14b812b6f04f3e0eaed05b5d520541e5547c72b7b3a8ebea044e7173163cafccc2cc8f9bb5c25b25a231ffe
6
+ metadata.gz: c76d2772db0925f71779c0bac1a2e92af20bcf398cc6e7cf943edeadb2014426554fba607b034fd68ac88bd26f2d42eef114de57332e83a4fe76558a9b2f0cea
7
+ data.tar.gz: '0489fffef87c32fbf6a7a9af4fbca77ef991437353211174617b6ea8e229e49accc85c39af93414ce1628a4472cc208d8075570abba5e8269b30cfb66000a40e'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.3.1](https://github.com/BoxcarsAI/boxcars/tree/v0.3.1) (2023-07-01)
4
+
5
+ [Full Changelog](https://github.com/BoxcarsAI/boxcars/compare/v0.2.16...v0.3.1)
6
+
7
+ **Closed issues:**
8
+
9
+ - Add a running log of prompts for debugging [\#99](https://github.com/BoxcarsAI/boxcars/issues/99)
10
+ - Anyway to create conversation? [\#73](https://github.com/BoxcarsAI/boxcars/issues/73)
11
+
12
+ **Merged pull requests:**
13
+
14
+ - now, when you call run on a train multiple times, it remembers the ru… [\#101](https://github.com/BoxcarsAI/boxcars/pull/101) ([francis](https://github.com/francis))
15
+
16
+ ## [v0.2.16](https://github.com/BoxcarsAI/boxcars/tree/v0.2.16) (2023-06-26)
17
+
18
+ [Full Changelog](https://github.com/BoxcarsAI/boxcars/compare/v0.2.15...v0.2.16)
19
+
20
+ **Implemented enhancements:**
21
+
22
+ - Support Sequel connection type [\#22](https://github.com/BoxcarsAI/boxcars/issues/22)
23
+
24
+ **Closed issues:**
25
+
26
+ - Using the SQL model This model's maximum context length is 4097 tokens [\#88](https://github.com/BoxcarsAI/boxcars/issues/88)
27
+
28
+ **Merged pull requests:**
29
+
30
+ - Add running logs [\#100](https://github.com/BoxcarsAI/boxcars/pull/100) ([francis](https://github.com/francis))
31
+ - create new Sequel boxcar, and refactor Active Record SQL boxcar [\#98](https://github.com/BoxcarsAI/boxcars/pull/98) ([francis](https://github.com/francis))
32
+ - Support for Sequel SQL connection types [\#97](https://github.com/BoxcarsAI/boxcars/pull/97) ([eltoob](https://github.com/eltoob))
33
+
3
34
  ## [v0.2.15](https://github.com/BoxcarsAI/boxcars/tree/v0.2.15) (2023-06-09)
4
35
 
5
36
  [Full Changelog](https://github.com/BoxcarsAI/boxcars/compare/v0.2.14...v0.2.15)
data/Gemfile.lock CHANGED
@@ -1,10 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- boxcars (0.2.16)
4
+ boxcars (0.3.2)
5
5
  google_search_results (~> 2.2)
6
6
  gpt4all (~> 0.0.4)
7
7
  hnswlib (~> 0.8)
8
+ nokogiri (~> 1.15)
8
9
  pgvector (~> 0.2)
9
10
  ruby-openai (~> 4.1)
10
11
 
@@ -55,7 +56,7 @@ GEM
55
56
  domain_name (0.5.20190701)
56
57
  unf (>= 0.0.5, < 1.0.0)
57
58
  dotenv (2.8.1)
58
- faraday (2.7.4)
59
+ faraday (2.7.9)
59
60
  faraday-net_http (>= 2.0, < 3.1)
60
61
  ruby2_keywords (>= 0.0.4)
61
62
  faraday-http-cache (2.5.0)
@@ -103,6 +104,14 @@ GEM
103
104
  netrc (0.11.0)
104
105
  nio4r (2.5.9)
105
106
  nio4r (2.5.9-java)
107
+ nokogiri (1.15.2-arm64-darwin)
108
+ racc (~> 1.4)
109
+ nokogiri (1.15.2-java)
110
+ racc (~> 1.4)
111
+ nokogiri (1.15.2-x86_64-darwin)
112
+ racc (~> 1.4)
113
+ nokogiri (1.15.2-x86_64-linux)
114
+ racc (~> 1.4)
106
115
  octokit (4.25.1)
107
116
  faraday (>= 1, < 3)
108
117
  sawyer (~> 0.9)
@@ -120,6 +129,8 @@ GEM
120
129
  protocol-hpack (~> 1.4)
121
130
  protocol-http (~> 0.18)
122
131
  public_suffix (5.0.1)
132
+ racc (1.7.1)
133
+ racc (1.7.1-java)
123
134
  rainbow (3.1.1)
124
135
  rake (13.0.6)
125
136
  regexp_parser (2.8.0)
data/boxcars.gemspec CHANGED
@@ -34,6 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency "google_search_results", "~> 2.2"
35
35
  spec.add_dependency "gpt4all", "~> 0.0.4"
36
36
  spec.add_dependency "hnswlib", "~> 0.8"
37
+ spec.add_dependency "nokogiri", "~> 1.15"
37
38
  spec.add_dependency "pgvector", "~> 0.2"
38
39
  spec.add_dependency "ruby-openai", "~> 4.1"
39
40
 
@@ -147,7 +147,7 @@ module Boxcars
147
147
  end
148
148
 
149
149
  def change_count(changes_code)
150
- return 0 unless changes_code
150
+ return 0 if changes_code.nil? || changes_code.empty? || changes_code =~ %r{^(None|N/A)$}i
151
151
 
152
152
  rollback_after_running do
153
153
  Boxcars.debug "computing change count with: #{changes_code}", :yellow
@@ -200,7 +200,8 @@ module Boxcars
200
200
  def error_message(err, stage)
201
201
  msg = err.message
202
202
  msg = ::Regexp.last_match(1) if msg =~ /^(.+)' for #<Boxcars::ActiveRecord/
203
- "#{stage} Error: #{msg} - please fix \"#{stage}:\" to not have this error"
203
+ msg.gsub!(/Boxcars::ActiveRecord::/, '')
204
+ "For the value you gave for #{stage}, fix this error: #{msg}"
204
205
  end
205
206
 
206
207
  def get_active_record_answer(text)
@@ -43,7 +43,7 @@ module Boxcars
43
43
  CTEMPLATE = [
44
44
  syst("You can do basic math, but for any hard calculations that a human could not do ",
45
45
  "in their head, use the following approach instead. ",
46
- "Return code written in the Ruby programming language that prints the results. ",
46
+ "Return code written in the Ruby programming language that prints the results to the console. ",
47
47
  "If anyone gives you a hard math problem, just ",
48
48
  "use the following format and we’ll take care of the rest:\n",
49
49
  "${{Question with hard calculation.}}\n",
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # A Boxcar that reads text from a URL.
5
+ class URLText < Boxcar
6
+ # the description of this boxcar
7
+ DESC = "useful when you want to get text from a URL."
8
+
9
+ # implements a boxcar that uses the Google SerpAPI to get answers to questions.
10
+ # @param name [String] The name of the boxcar. Defaults to classname.
11
+ # @param description [String] A description of the boxcar. Defaults to SERPDESC.
12
+ def initialize(name: "FetchURL", description: DESC)
13
+ super(name: name, description: description)
14
+ end
15
+
16
+ # Get text from a url.
17
+ # @param url [String] The url
18
+ # @return [String] The text for the url.
19
+ def run(url)
20
+ url = URI.parse(url)
21
+ get_answer(url)
22
+ end
23
+
24
+ private
25
+
26
+ def html_to_text(url, response)
27
+ Nokogiri::HTML(response.body).css(%w[h1 h2 h3 h4 h5 h6 p a].join(",")).map do |e|
28
+ itxt = e.inner_text.strip
29
+ itxt = itxt.gsub(/[[:space:]]+/, " ") # remove extra spaces
30
+ # next if itxt.nil? || itxt.empty?
31
+ if e.name == "a"
32
+ href = e.attributes["href"]&.value
33
+ href = URI.join(url, href).to_s if href =~ %r{^/}
34
+ "[#{itxt}](#{href})" # if e.attributes["href"]&.value =~ /^http/
35
+ else
36
+ itxt
37
+ end
38
+ end.compact.join("\n\n")
39
+ end
40
+
41
+ def get_answer(url)
42
+ response = Net::HTTP.get_response(url)
43
+ if response.is_a?(Net::HTTPSuccess)
44
+ return Result.from_text(response.body) if response.content_type == "text/plain"
45
+
46
+ if response.content_type == "text/html"
47
+ # return only the top level text
48
+ txt = html_to_text(url, response)
49
+ Result.from_text(txt)
50
+ else
51
+ Result.from_text(response.body)
52
+ end
53
+ else
54
+ Result.new(status: :error, explanation: "Error with url: #{response.code} #{response.message}")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -17,12 +17,12 @@ module Boxcars
17
17
 
18
18
  # Input keys this chain expects.
19
19
  def input_keys
20
- raise NotImplementedError
20
+ [:question]
21
21
  end
22
22
 
23
23
  # Output keys this chain expects.
24
24
  def output_keys
25
- raise NotImplementedError
25
+ [:answer]
26
26
  end
27
27
 
28
28
  # Check that all inputs are present.
@@ -63,7 +63,11 @@ module Boxcars
63
63
  # @return [String] The answer to the question.
64
64
  def run(*args, **kwargs)
65
65
  rv = conduct(*args, **kwargs)
66
- rv.is_a?(Result) ? rv.to_answer : rv
66
+ rv = rv[:answer] if rv.is_a?(Hash) && rv.key?(:answer)
67
+ return rv.answer if rv.is_a?(Result)
68
+ return rv[output_keys[0]] if rv.is_a?(Hash)
69
+
70
+ rv
67
71
  end
68
72
 
69
73
  # Get an extended answer from the boxcar.
@@ -74,6 +78,7 @@ module Boxcars
74
78
  def conduct(*args, **kwargs)
75
79
  Boxcars.info "> Entering #{name}#run", :gray, style: :bold
76
80
  rv = depart(*args, **kwargs)
81
+ remember_history(rv)
77
82
  Boxcars.info "< Exiting #{name}#run", :gray, style: :bold
78
83
  rv
79
84
  end
@@ -94,8 +99,65 @@ module Boxcars
94
99
  [:user, strs.join]
95
100
  end
96
101
 
102
+ # history entries
103
+ def self.hist
104
+ [:history, ""]
105
+ end
106
+
107
+ # save this boxcar to a file
108
+ def save(path:)
109
+ File.write(path, YAML.dump(self))
110
+ end
111
+
112
+ # load this boxcar from a file
113
+ # rubocop:disable Security/YAMLLoad
114
+ def load(path:)
115
+ YAML.load(File.read(path))
116
+ end
117
+ # rubocop:enable Security/YAMLLoad
118
+
119
+ def schema
120
+ params = input_keys.map do |key|
121
+ "<param name=\"#{key}\" data-type=\"String\" required=\"true\" description=\"#{key}\" />"
122
+ end.join("\n")
123
+ <<~SCHEMA.freeze
124
+ <tool>
125
+ <tool name="#{name}" version="0.1" description="#{description}">
126
+ <params>
127
+ #{params}
128
+ </params>
129
+ </tool>
130
+ SCHEMA
131
+ end
132
+
97
133
  private
98
134
 
135
+ # remember the history of this boxcar. Take the current intermediate steps and
136
+ # create a history that can be used on the next run.
137
+ # @param current_results [Array<Hash>] The current results.
138
+ def remember_history(current_results)
139
+ return unless current_results[:intermediate_steps] && is_a?(Train)
140
+
141
+ # insert conversation history into the prompt
142
+ history = []
143
+ history << Boxcar.user(key_and_value_text(question_prefix, current_results[:input]))
144
+ current_results[:intermediate_steps].each do |action, obs|
145
+ if action.is_a?(TrainAction)
146
+ obs = Observation.new(status: :ok, note: obs) if obs.is_a?(String)
147
+ next if obs.status != :ok
148
+
149
+ history << Boxcar.assi("#{thought_prefix}#{action.log}", "\n",
150
+ key_and_value_text(observation_prefix, obs.note))
151
+ else
152
+ Boxcars.error "Unknown action: #{action}", :red
153
+ end
154
+ end
155
+ final_answer = key_and_value_text(final_answer_prefix, current_results[:output])
156
+ history << Boxcar.assi(
157
+ key_and_value_text(thought_prefix, "I know the final answer\n#{final_answer}\n"))
158
+ prompt.add_history(history)
159
+ end
160
+
99
161
  # Get an answer from the boxcar.
100
162
  def run_boxcar(inputs:, return_only_outputs: false)
101
163
  inputs = our_inputs(inputs)
@@ -117,13 +179,11 @@ module Boxcars
117
179
  if kwargs.empty?
118
180
  raise Boxcars::ArgumentError, "run supports only one positional argument." if args.length != 1
119
181
 
120
- return run_boxcar(inputs: args[0])[output_keys.first]
121
- end
122
- if args.empty?
123
- ans = run_boxcar(inputs: kwargs)
124
- return ans[output_keys.first]
182
+ return run_boxcar(inputs: args[0])
125
183
  end
126
184
 
185
+ return run_boxcar(inputs: kwargs) if args.empty?
186
+
127
187
  raise Boxcars::ArgumentError, "run supported with either positional or keyword arguments but not both. Got args" \
128
188
  ": #{args} and kwargs: #{kwargs}."
129
189
  end
@@ -148,10 +208,12 @@ module Boxcars
148
208
  end
149
209
  end
150
210
 
211
+ require "boxcars/observation"
151
212
  require "boxcars/result"
152
213
  require "boxcars/boxcar/engine_boxcar"
153
214
  require "boxcars/boxcar/calculator"
154
215
  require "boxcars/boxcar/google_search"
216
+ require "boxcars/boxcar/url_text"
155
217
  require "boxcars/boxcar/wikipedia_search"
156
218
  require "boxcars/boxcar/sql_base"
157
219
  require "boxcars/boxcar/sql_active_record"
@@ -5,7 +5,7 @@ module Boxcars
5
5
  class Conversation
6
6
  attr_reader :lines, :show_roles
7
7
 
8
- PEOPLE = %i[system user assistant].freeze
8
+ PEOPLE = %i[system user assistant history].freeze
9
9
 
10
10
  def initialize(lines: [], show_roles: false)
11
11
  @lines = lines
@@ -64,6 +64,23 @@ module Boxcars
64
64
  @lines += conversation.lines
65
65
  end
66
66
 
67
+ # insert converation above history line if it is present
68
+ # @param conversation [Conversation] The conversation to add
69
+ def add_history(conversation)
70
+ # find the history line
71
+ hi = lines.rindex { |ln| ln[0] == :history }
72
+ return unless hi
73
+
74
+ @lines = @lines.dup
75
+
76
+ # insert the conversation above the history line
77
+ @lines.insert(hi, *conversation.lines)
78
+ end
79
+
80
+ def no_history
81
+ @lines.reject { |ln| ln[0] == :history }
82
+ end
83
+
67
84
  # return just the messages for the conversation
68
85
  def message_text
69
86
  lines.map(&:last).join("\n")
@@ -73,7 +90,7 @@ module Boxcars
73
90
  # @param inputs [Hash] The inputs to use for the prompt.
74
91
  # @return [Hash] The formatted prompt { messages: ...}
75
92
  def as_messages(inputs = nil)
76
- { messages: lines.map { |ln| { role: ln.first, content: ln.last % inputs } } }
93
+ { messages: no_history.map { |ln| { role: ln.first, content: ln.last % inputs } } }
77
94
  rescue ::KeyError => e
78
95
  first_line = e.message.to_s.split("\n").first
79
96
  Boxcars.error "Missing prompt input key: #{first_line}"
@@ -85,9 +102,9 @@ module Boxcars
85
102
  # @return [Hash] The formatted prompt { prompt: "..."}
86
103
  def as_prompt(inputs = nil)
87
104
  if show_roles
88
- lines.map { |ln| format("#{ln.first}: #{ln.last}", inputs) }.join("\n\n")
105
+ no_history.map { |ln| format("#{ln.first}: #{ln.last}", inputs) }.compact.join("\n\n")
89
106
  else
90
- lines.map { |ln| format(ln.last, inputs) }.join("\n\n")
107
+ no_history.map { |ln| format(ln.last, inputs) }.compact.join("\n\n")
91
108
  end
92
109
  rescue ::KeyError => e
93
110
  first_line = e.message.to_s.split("\n").first
@@ -36,5 +36,16 @@ module Boxcars
36
36
  new_prompt.conversation.add_conversation(conversation)
37
37
  new_prompt
38
38
  end
39
+
40
+ # add conversation history to the prompt
41
+ # @param history [Hash] The history to add to the prompt.
42
+ def add_history(history)
43
+ conversation.add_history(Conversation.new(lines: history))
44
+ end
45
+
46
+ # print the prompt
47
+ def to_s
48
+ conversation.to_s
49
+ end
39
50
  end
40
51
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxcars
4
+ # used by Boxcars to return structured result and additional context
5
+ class Observation
6
+ attr_reader :note, :status, :added_context
7
+
8
+ # @param note [String] The note to use for the result
9
+ # @param status [Symbol] :ok or :error
10
+ # @param added_context [Hash] Any additional context to add to the result
11
+ def initialize(note:, status: :ok, **added_context)
12
+ @note = note
13
+ @status = status
14
+ @added_context = added_context
15
+ end
16
+
17
+ # @return [Hash] The result as a hash
18
+ def to_h
19
+ {
20
+ note: note,
21
+ status: status
22
+ }.merge(added_context).compact
23
+ end
24
+
25
+ # @return [String] The result as a json string
26
+ def to_json(*args)
27
+ JSON.generate(to_h, *args)
28
+ end
29
+
30
+ # @return [String] An explanation of the result
31
+ def to_s
32
+ note
33
+ end
34
+
35
+ # create a new Observaton from a text string with a status of :ok
36
+ # @param note [String] The text to use for the observation
37
+ # @param added_context [Hash] Any additional context to add to the result
38
+ # @return [Boxcars::Observation] The observation
39
+ def self.ok(note, **kwargs)
40
+ new(note: note, status: :ok, **kwargs)
41
+ end
42
+
43
+ # create a new Observaton from a text string with a status of :error
44
+ # @param note [String] The text to use for the observation
45
+ # @param added_context [Hash] Any additional context to add to the result
46
+ # @return [Boxcars::Observation] The observation
47
+ def self.err(note, **kwargs)
48
+ new(note: note, status: :error, **kwargs)
49
+ end
50
+ end
51
+ end
@@ -8,7 +8,7 @@ module Boxcars
8
8
  def call(code:)
9
9
  Boxcars.debug "RubyREPL: #{code}", :yellow
10
10
 
11
- # wrap the code in an excption block so we can catch errors
11
+ # wrap the code in an exception block so we can catch errors
12
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|
@@ -19,6 +19,8 @@ module Boxcars
19
19
  if output =~ /^Error: /
20
20
  Boxcars.debug output, :red
21
21
  Result.from_error(output, code: code)
22
+ elsif output.blank?
23
+ Result.from_error("The code you gave me did not print a result", code: code)
22
24
  else
23
25
  output = ::Regexp.last_match(1) if output =~ /^\s*Answer:\s*(.*)$/m
24
26
  Boxcars.debug "Answer: #{output}", :yellow, style: :bold
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ # base class for all XML trains
6
+ module Boxcars
7
+ # A Train using XML for prompting and execution.
8
+ class XMLTrain < Train
9
+ # A Train will use a engine to run a series of boxcars.
10
+ # @param boxcars [Array<Boxcars::Boxcar>] The boxcars to run.
11
+ # @param prompt [Boxcars::Prompt] The prompt to use.
12
+ # @param engine [Boxcars::Engine] The engine to use for this train.
13
+ # @param kwargs [Hash] Additional arguments including: name, description, top_k, return_direct, and stop
14
+ # @abstract
15
+ def initialize(boxcars:, prompt:, engine: nil, **kwargs)
16
+ super
17
+ end
18
+
19
+ def init_prefixes
20
+ @thought_prefix ||= "<thought>"
21
+ @observation_prefix ||= "<observation>"
22
+ @final_answer_prefix ||= "<final_answer>"
23
+ @answer_prefix ||= "<answer>"
24
+ @question_prefix ||= "<question>"
25
+ @output_prefix ||= "<output>"
26
+ end
27
+
28
+ def close_tag(tag)
29
+ tag.to_s.sub("<", "</") if tag.to_s[0] == "<"
30
+ end
31
+
32
+ # the xml to describe the boxcars
33
+ def boxcars_xml
34
+ schema = boxcars.map(&:schema).join("\n")
35
+ "<boxcars>\n#{schema}</boxcars>"
36
+ end
37
+
38
+ # @return Hash The additional variables for this boxcar.
39
+ def prediction_additional(_inputs)
40
+ { boxcars_xml: boxcars_xml, next_actions: next_actions }.merge super
41
+ end
42
+
43
+ def build_output(text)
44
+ if text =~ /#{close_tag(thought_prefix)}/
45
+ "<data>#{engine_prefix}#{text}</data>"
46
+ else
47
+ "<data>#{text}</data>"
48
+ end
49
+ end
50
+
51
+ # Extract the boxcar and input from the engine output.
52
+ # @param text [String] The output from the engine.
53
+ # @return [Array<Boxcars::Boxcar, String>] The boxcar and input.
54
+ def extract_boxcar_and_input(text)
55
+ get_action_and_input(engine_output: build_output(text))
56
+ rescue StandardError => e
57
+ Boxcars.debug("Error: #{e.message}", :red)
58
+ [:error, e.message]
59
+ end
60
+
61
+ private
62
+
63
+ def parse_output(engine_output)
64
+ doc = Nokogiri::XML("<data>#{engine_prefix}#{engine_output}\n</data>")
65
+ keys = doc.element_children.first.element_children.map(&:name).map(&:to_sym)
66
+ keys.to_h do |key|
67
+ [key, doc.at_xpath("//#{key}")&.text]
68
+ end
69
+ end
70
+
71
+ def child_keys(xnode)
72
+ xnode.children.map(&:name).map(&:to_sym)
73
+ end
74
+
75
+ # get next action and input using an XNode
76
+ # @param xnode [XNode] The XNode to use.
77
+ # @return [Array<String, String>] The action and input.
78
+ def xn_get_action_and_input(xnode)
79
+ action = xnode.xtext("//action")
80
+ action_input = xnode.xtext("//action_input")
81
+ thought = xnode.xtext("//thought")
82
+ final_answer = xnode.xtext("//final_answer")
83
+
84
+ # the thought should be the frist line here if it doesn't start with "Action:"
85
+ Boxcars.debug("Thought: #{thought}", :yellow)
86
+
87
+ if final_answer.present?
88
+ Result.new(status: :ok, answer: final_answer, explanation: final_answer)
89
+ else
90
+ # we have an unexpected output from the engine
91
+ unless action.present? && action_input.present?
92
+ return [:error, "You gave me an improperly formatted answer or didn't use tags."]
93
+ end
94
+
95
+ Boxcars.debug("Action: #{action}\nAction Input: #{action_input}", :yellow)
96
+ [action, action_input]
97
+ end
98
+ end
99
+
100
+ # Parse out the action and input from the engine output.
101
+ # @param engine_output [String] The output from the engine.
102
+ # @return [Array<String>] The action and input.
103
+ def get_action_and_input(engine_output:)
104
+ xn_get_action_and_input(XNode.from_xml(engine_output))
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ # Agent for the MRKL chain
6
+ module Boxcars
7
+ # A Train using the zero-shot react method and only XML in the prompt.
8
+ class XMLZeroShot < XMLTrain
9
+ attr_reader :boxcars
10
+ attr_accessor :wants_next_actions
11
+
12
+ # @param boxcars [Array<Boxcars::Boxcar>] The boxcars to run.
13
+ # @param engine [Boxcars::Engine] The engine to use for this train.
14
+ # @param name [String] The name of the train. Defaults to 'Zero Shot'.
15
+ # @param description [String] The description of the train. Defaults to 'Zero Shot Train'.
16
+ # @param prompt [Boxcars::Prompt] The prompt to use. Defaults to the built-in prompt.
17
+ # @param kwargs [Hash] Additional arguments to pass to the train. wants_next_actions: true
18
+ def initialize(boxcars:, engine: nil, name: 'Zero Shot XML', description: 'Zero Shot Train wiht XML', prompt: nil, **kwargs)
19
+ @engine_prefix = '<thought>'
20
+ @wants_next_actions = kwargs.fetch(:wants_next_actions, false)
21
+ prompt ||= my_prompt
22
+ super(engine: engine, boxcars: boxcars, prompt: prompt, name: name, description: description, **kwargs)
23
+ end
24
+
25
+ private
26
+
27
+ CTEMPLATE = [
28
+ syst("<training>Answer the following questions as best you can. You have access to the following tools for actions:\n",
29
+ "%<boxcars_xml>s",
30
+ "Use the following format making sure all open tags have closing tags:\n",
31
+ " <question>the input question you must answer</question>\n",
32
+ " <thought>you should always think about what to do</thought>\n",
33
+ " <action>the action to take, from this action list above</action>\n",
34
+ " <action_input>input to the action</action_input>\n",
35
+ " <observation>the result of the action</observation>\n",
36
+ " ... (this Thought/Action/Action Input/Observation sequence can repeat N times)\n",
37
+ " <thought>I know the final answer</thought>\n",
38
+ " <final_answer>the final answer to the original input question</final_answer>\n",
39
+ "-- FORMAT END -\n",
40
+ "Your answer should always have begin and end tags for each element.\n",
41
+ "Also make sure to specify a question for the action_input.\n",
42
+ "Finally, if you can deduct the answer from the question or observation, you can ",
43
+ "jump to final_answer and give me the answer.\n",
44
+ "</training>"),
45
+ hist, # insert thoughts here from previous runs
46
+ user("<question>%<input>s</question>"),
47
+ assi("<thought>%<agent_scratchpad>s")
48
+ ].freeze
49
+
50
+ # The prompt to use for the train.
51
+ def my_prompt
52
+ @conversation ||= Conversation.new(lines: CTEMPLATE)
53
+ @my_prompt ||= ConversationPrompt.new(
54
+ conversation: @conversation,
55
+ input_variables: [:input],
56
+ other_inputs: [:boxcars_xml, :next_actions, :agent_scratchpad],
57
+ output_variables: [:answer])
58
+ end
59
+ end
60
+ end
@@ -4,7 +4,7 @@
4
4
  module Boxcars
5
5
  # A Train using the zero-shot react method.
6
6
  class ZeroShot < Train
7
- attr_reader :boxcars, :observation_prefix, :engine_prefix
7
+ attr_reader :boxcars, :observation_prefix
8
8
  attr_accessor :wants_next_actions
9
9
 
10
10
  # @param boxcars [Array<Boxcars::Boxcar>] The boxcars to run.
@@ -14,11 +14,9 @@ module Boxcars
14
14
  # @param prompt [Boxcars::Prompt] The prompt to use. Defaults to the built-in prompt.
15
15
  # @param kwargs [Hash] Additional arguments to pass to the train. wants_next_actions: true
16
16
  def initialize(boxcars:, engine: nil, name: 'Zero Shot', description: 'Zero Shot Train', prompt: nil, **kwargs)
17
- @observation_prefix = 'Observation: '
18
- @engine_prefix = 'Thought:'
19
17
  @wants_next_actions = kwargs.fetch(:wants_next_actions, false)
20
18
  prompt ||= my_prompt
21
- super(engine: engine, boxcars: boxcars, prompt: prompt, name: name, description: description)
19
+ super(engine: engine, boxcars: boxcars, prompt: prompt, name: name, description: description, **kwargs)
22
20
  end
23
21
 
24
22
  # @return Hash The additional variables for this boxcar.
@@ -31,6 +29,8 @@ module Boxcars
31
29
  # @return [Array<Boxcars::Boxcar, String>] The boxcar and input.
32
30
  def extract_boxcar_and_input(text)
33
31
  get_action_and_input(engine_output: text)
32
+ rescue StandardError => e
33
+ [:error, e.message]
34
34
  end
35
35
 
36
36
  private
@@ -85,27 +85,15 @@ module Boxcars
85
85
  "%<next_actions>s\n",
86
86
  "Remember to start a line with \"Final Answer:\" to give me the final answer.\n",
87
87
  "Also make sure to specify a question for the Action Input.\n",
88
+ "Finally, if you can deduct the answer from the question or observation, you can ",
89
+ "start with \"Final Answer:\" and give me the answer.\n",
88
90
  "Begin!"),
91
+ # insert thoughts here from previous runs
92
+ hist,
89
93
  user("Question: %<input>s"),
90
94
  assi("Thought: %<agent_scratchpad>s")
91
95
  ].freeze
92
96
 
93
- def boxcar_names
94
- @boxcar_names ||= "[#{boxcars.map(&:name).join(', ')}]"
95
- end
96
-
97
- def boxcar_descriptions
98
- @boxcar_descriptions ||= boxcars.map { |boxcar| "#{boxcar.name}: #{boxcar.description}" }.join("\n")
99
- end
100
-
101
- def next_actions
102
- if wants_next_actions
103
- "Next Actions: Up to 3 logical suggested next questions for the user to ask after getting this answer.\n"
104
- else
105
- ""
106
- end
107
- end
108
-
109
97
  # The prompt to use for the train.
110
98
  def my_prompt
111
99
  @conversation ||= Conversation.new(lines: CTEMPLATE)
data/lib/boxcars/train.rb CHANGED
@@ -4,7 +4,8 @@ module Boxcars
4
4
  # @abstract
5
5
  class Train < EngineBoxcar
6
6
  attr_reader :boxcars, :return_values, :return_intermediate_steps,
7
- :max_iterations, :early_stopping_method, :name_to_boxcar_map
7
+ :max_iterations, :early_stopping_method, :name_to_boxcar_map,
8
+ :observation_prefix, :thought_prefix, :final_answer_prefix, :answer_prefix, :question_prefix, :engine_prefix
8
9
 
9
10
  # A Train will use a engine to run a series of boxcars.
10
11
  # @param boxcars [Array<Boxcars::Boxcar>] The boxcars to run.
@@ -16,15 +17,25 @@ module Boxcars
16
17
  @boxcars = boxcars
17
18
  @name_to_boxcar_map = boxcars.to_h { |boxcar| [boxcar.name, boxcar] }
18
19
  @return_values = [:output]
19
- @return_intermediate_steps = kwargs.delete(:return_intermediate_steps) || false
20
+ @return_intermediate_steps = kwargs.fetch(:return_intermediate_steps, true)
21
+ kwargs.delete(:return_intermediate_steps)
20
22
  @max_iterations = kwargs.delete(:max_iterations) || 25
21
23
  @early_stopping_method = kwargs.delete(:early_stopping_method) || "force"
22
- kwargs[:stop] ||= ["\n#{observation_prefix}"]
24
+ init_prefixes
25
+ kwargs[:stop] = ["\n#{observation_prefix}"] unless kwargs.key?(:stop)
23
26
 
24
27
  super(prompt: prompt, engine: engine, **kwargs)
25
28
  end
26
29
 
27
- # Extract the boxcar name and input from the text.
30
+ def init_prefixes
31
+ @thought_prefix ||= "Thought: "
32
+ @observation_prefix ||= "Observation: "
33
+ @final_answer_prefix ||= "Final Answer: "
34
+ @answer_prefix ||= "Answer:"
35
+ @question_prefix ||= "Question: "
36
+ end
37
+
38
+ # Callback to process the action/action input of a train.
28
39
  # @param text [String] The text to extract from.
29
40
  def extract_boxcar_and_input(text)
30
41
  Result.new(status: :ok, answer: text, explanation: engine_output)
@@ -37,7 +48,7 @@ module Boxcars
37
48
  thoughts = ""
38
49
  intermediate_steps.each do |action, observation|
39
50
  thoughts += action.is_a?(String) ? action : " #{action.log}"
40
- thoughts += "\n#{observation_prefix}#{observation}\n#{engine_prefix}"
51
+ thoughts += "\n#{observation_text(observation)}\n#{engine_prefix}"
41
52
  end
42
53
  thoughts
43
54
  end
@@ -51,7 +62,7 @@ module Boxcars
51
62
  loop do
52
63
  full_inputs[:agent_scratchpad] += full_output
53
64
  output = predict(**full_inputs)
54
- full_output += output
65
+ full_output += output.to_s
55
66
  parsed_output = extract_boxcar_and_input(full_output)
56
67
  break unless parsed_output.nil?
57
68
  end
@@ -88,14 +99,12 @@ module Boxcars
88
99
  # the input keys
89
100
  # @return [Array<Symbol>] The input keys.
90
101
  def input_keys
91
- list = prompt.input_variables
92
- list.delete(:agent_scratchpad)
93
- list
102
+ prompt.input_variables - [:agent_scratchpad]
94
103
  end
95
104
 
96
105
  # the output keys
97
106
  def output_keys
98
- return return_values + ["intermediate_steps"] if return_intermediate_steps
107
+ return return_values + [:intermediate_steps] if return_intermediate_steps
99
108
 
100
109
  return_values
101
110
  end
@@ -116,17 +125,10 @@ module Boxcars
116
125
  def pre_return(output, intermediate_steps)
117
126
  Boxcars.debug output.log, :yellow, style: :bold
118
127
  final_output = output.return_values
119
- final_output["intermediate_steps"] = intermediate_steps if return_intermediate_steps
128
+ final_output[:intermediate_steps] = intermediate_steps if return_intermediate_steps
120
129
  final_output
121
130
  end
122
131
 
123
- # the prefix for the engine
124
- # @param return_direct [Boolean] Whether to return directly.
125
- # @return [String] The prefix.
126
- def engine_prefix(return_direct)
127
- return_direct ? "" : engine_prefix
128
- end
129
-
130
132
  # validate the prompt
131
133
  # @param values [Hash] The values to validate.
132
134
  # @return [Hash] The validated values.
@@ -159,7 +161,7 @@ module Boxcars
159
161
  thoughts = ""
160
162
  intermediate_steps.each do |action, observation|
161
163
  thoughts += action.log
162
- thoughts += "\n#{observation_prefix}#{observation}\n#{engine_prefix}"
164
+ thoughts += "\n#{observation_text(observation)}\n#{engine_prefix}"
163
165
  end
164
166
  thoughts += "\n\nI now need to return a final answer based on the previous steps:"
165
167
  new_inputs = { agent_scratchpad: thoughts, stop: _stop }
@@ -170,6 +172,7 @@ module Boxcars
170
172
  TrainFinish.new({ output: full_output }, full_output)
171
173
  else
172
174
  boxcar, boxcar_input = parsed_output
175
+ Boxcars.debug "Got boxcar #{boxcar} and input #{boxcar_input}"
173
176
  if boxcar == finish_boxcar_name
174
177
  TrainFinish.new({ output: boxcar_input }, full_output)
175
178
  else
@@ -194,22 +197,24 @@ module Boxcars
194
197
 
195
198
  if (boxcar = name_to_boxcar_map[output.boxcar])
196
199
  begin
197
- observation = boxcar.run(output.boxcar_input)
200
+ observation = Observation.ok(boxcar.run(output.boxcar_input))
198
201
  return_direct = boxcar.return_direct
199
202
  rescue Boxcars::ConfigurationError, Boxcars::SecurityError => e
200
203
  raise e
201
204
  rescue StandardError => e
202
205
  Boxcars.error "Error in #{boxcar.name} boxcar#call: #{e}\nbt:#{caller[0..5].join("\n ")}", :red
203
- observation = "Error - #{e}, correct and try again."
206
+ observation = Observation.err("Error - #{e}, correct and try again.")
204
207
  end
205
208
  elsif output.boxcar == :error
206
209
  observation = output.log
207
210
  return_direct = false
208
211
  else
209
- observation = "#{output.boxcar} is not a valid boxcar, try another one."
212
+ observation = Observation.err("Error - #{output.boxcar} is not a valid action, try again.")
210
213
  return_direct = false
211
214
  end
212
- Boxcars.debug "Observation: #{observation}", :green
215
+ # rubocop:disable Lint/RedundantStringCoercion
216
+ Boxcars.debug "Observation: #{observation.to_s}", :green
217
+ # rubocop:enable Lint/RedundantStringCoercion
213
218
  intermediate_steps.append([output, observation])
214
219
  if return_direct
215
220
  output = TrainFinish.new({ return_values[0] => observation }, "")
@@ -220,9 +225,46 @@ module Boxcars
220
225
  output = return_stopped_response(early_stopping_method, intermediate_steps, **inputs)
221
226
  pre_return(output, intermediate_steps)
222
227
  end
228
+
229
+ def key_and_value_text(key, value)
230
+ value = value.to_s
231
+ if key =~ /^<(?<tag_name>[[:word:]]+)>$/
232
+ # we need a close tag too
233
+ "#{key}#{value}</#{Regexp.last_match[:tag_name]}>"
234
+ else
235
+ "#{key}#{value}"
236
+ end
237
+ end
238
+
239
+ # this is for the scratchpad
240
+ def observation_text(observation)
241
+ key_and_value_text(observation_prefix, observation)
242
+ end
243
+
244
+ def question_text(question)
245
+ key_and_value_text(question_prefix, question)
246
+ end
247
+
248
+ def boxcar_names
249
+ @boxcar_names ||= boxcars.map(&:name).join(', ')
250
+ end
251
+
252
+ def boxcar_descriptions
253
+ @boxcar_descriptions ||= boxcars.map { |boxcar| "#{boxcar.name}: #{boxcar.description}" }.join("\n")
254
+ end
255
+
256
+ def next_actions
257
+ if wants_next_actions
258
+ "Next Actions: Up to 3 logical suggested next questions for the user to ask after getting this answer.\n"
259
+ else
260
+ ""
261
+ end
262
+ end
223
263
  end
224
264
  end
225
265
 
226
266
  require "boxcars/train/train_action"
227
267
  require "boxcars/train/train_finish"
228
268
  require "boxcars/train/zero_shot"
269
+ require "boxcars/train/xml_train"
270
+ require "boxcars/train/xml_zero_shot"
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Boxcars
4
4
  # The current version of the gem.
5
- VERSION = "0.2.16"
5
+ VERSION = "0.3.2"
6
6
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module Boxcars
6
+ class XNode
7
+ attr_accessor :node, :children, :attributes
8
+
9
+ def initialize(node)
10
+ @node = node
11
+ @valid_names = []
12
+ @children = {}
13
+ # @attributes = node.attributes.transform_values(&:value)
14
+ @attributes = node.attributes.values.to_h { |a| [a.name.to_sym, a.value] }
15
+
16
+ node.children.each do |child|
17
+ next if child.text?
18
+
19
+ child_node = XNode.new(child)
20
+ if @children[child.name].nil?
21
+ @valid_names << child.name.to_sym
22
+ @children[child.name] = child_node
23
+ elsif @children[child.name].is_a?(Array)
24
+ @children[child.name] << child_node
25
+ else
26
+ @children[child.name] = [@children[child.name], child_node]
27
+ end
28
+ end
29
+ end
30
+
31
+ def self.from_xml(xml)
32
+ doc = Nokogiri::XML.parse(xml)
33
+ raise XmlError, "XML is not valid: #{doc.errors.map { |e| "#{e.line}:#{e.column} #{e.message}" }}" if doc.errors.any?
34
+
35
+ XNode.new(doc.root)
36
+ end
37
+
38
+ def xml
39
+ @node.to_xml
40
+ end
41
+
42
+ def text
43
+ @node.text
44
+ end
45
+
46
+ def xpath(path)
47
+ @node.xpath(path)
48
+ end
49
+
50
+ def xtext(path)
51
+ rv = xpath(path)&.text&.gsub(/[[:space:]]+/, " ")&.strip
52
+ return nil if rv.empty?
53
+
54
+ rv
55
+ end
56
+
57
+ def stext
58
+ @stext ||= text.gsub(/[[:space:]]+/, " ").strip # remove extra spaces
59
+ end
60
+
61
+ def [](key)
62
+ @children[key.to_s]
63
+ end
64
+
65
+ def method_missing(name, *args)
66
+ return @children[name.to_s] if @children.key?(name.to_s)
67
+
68
+ super
69
+ end
70
+
71
+ def respond_to_missing?(method_name, include_private = false)
72
+ @valid_names.include?(method) || super
73
+ end
74
+ end
75
+ end
data/lib/boxcars.rb CHANGED
@@ -22,6 +22,9 @@ module Boxcars
22
22
  # Error class for all Boxcars key errors.
23
23
  class KeyError < Error; end
24
24
 
25
+ # Error class for all Boxcars XML errors.
26
+ class XmlError < Error; end
27
+
25
28
  # Configuration contains gem settings
26
29
  class Configuration
27
30
  attr_writer :openai_access_token, :serpapi_api_key
@@ -179,6 +182,7 @@ module Boxcars
179
182
  end
180
183
 
181
184
  require "boxcars/version"
185
+ require "boxcars/x_node"
182
186
  require "boxcars/prompt"
183
187
  require "boxcars/conversation_prompt"
184
188
  require "boxcars/conversation"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: boxcars
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.16
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Francis Sullivan
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2023-06-26 00:00:00.000000000 Z
12
+ date: 2023-07-10 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: google_search_results
@@ -53,6 +53,20 @@ dependencies:
53
53
  - - "~>"
54
54
  - !ruby/object:Gem::Version
55
55
  version: '0.8'
56
+ - !ruby/object:Gem::Dependency
57
+ name: nokogiri
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '1.15'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '1.15'
56
70
  - !ruby/object:Gem::Dependency
57
71
  name: pgvector
58
72
  requirement: !ruby/object:Gem::Requirement
@@ -113,6 +127,7 @@ files:
113
127
  - lib/boxcars/boxcar/sql_base.rb
114
128
  - lib/boxcars/boxcar/sql_sequel.rb
115
129
  - lib/boxcars/boxcar/swagger.rb
130
+ - lib/boxcars/boxcar/url_text.rb
116
131
  - lib/boxcars/boxcar/vector_answer.rb
117
132
  - lib/boxcars/boxcar/wikipedia_search.rb
118
133
  - lib/boxcars/conversation.rb
@@ -122,12 +137,15 @@ files:
122
137
  - lib/boxcars/engine/gpt4all_eng.rb
123
138
  - lib/boxcars/engine/openai.rb
124
139
  - lib/boxcars/generation.rb
140
+ - lib/boxcars/observation.rb
125
141
  - lib/boxcars/prompt.rb
126
142
  - lib/boxcars/result.rb
127
143
  - lib/boxcars/ruby_repl.rb
128
144
  - lib/boxcars/train.rb
129
145
  - lib/boxcars/train/train_action.rb
130
146
  - lib/boxcars/train/train_finish.rb
147
+ - lib/boxcars/train/xml_train.rb
148
+ - lib/boxcars/train/xml_zero_shot.rb
131
149
  - lib/boxcars/train/zero_shot.rb
132
150
  - lib/boxcars/vector_search.rb
133
151
  - lib/boxcars/vector_store.rb
@@ -147,6 +165,7 @@ files:
147
165
  - lib/boxcars/vector_store/pgvector/search.rb
148
166
  - lib/boxcars/vector_store/split_text.rb
149
167
  - lib/boxcars/version.rb
168
+ - lib/boxcars/x_node.rb
150
169
  homepage: https://github.com/BoxcarsAI/boxcars
151
170
  licenses:
152
171
  - MIT