foobara-llm-backed-command 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 932878d2d01d4d804a4aca75cf53ccbbff901cf2e54067d52a781298d3ebea2a
4
- data.tar.gz: 96c9c8a664b7fa493d740a222b5429d2259af317131c2ef4ebe23be6a730a68f
3
+ metadata.gz: b425fcdb4e888c59b2bc32f4a7c4a4d066b11cf6fcfee6f6dcb8fc00da88cc21
4
+ data.tar.gz: b27ebd00c36a49a2e79001e1122e783a4dcd1406a4d5ef61891af726c64821df
5
5
  SHA512:
6
- metadata.gz: 947393bec6c00defe7198ea96f69c34a90f27d2beb9c35531e39b09c436c172b243a93e1be49b38cb64ecaf73f8a5ee572868a3cf7de89f343b39041ab47d650
7
- data.tar.gz: f51fb84a77878d535c28699879c8ee7bc4dd643eaffbb3269aa679f994170ec0fc56c99bcb55c4c7f8570f95152bc8a12440860fcc552216d10d435ce8cad861
6
+ metadata.gz: 24facf55a6108124f8f0109b53acd0e2b5782d52f4ee812f8a7f223f725cdaedbb79a3bad11b0c67efa0253c612edacf50f7db56025c91991dac23e7344306f7
7
+ data.tar.gz: 80d4bd1e19ecdfd28b58ff84026ed5245c06dce067a0a7dffb5ed887bf97fe35395274eb2f25a581ab08ba53a7448b9e01dcaf67c032ef07e70c4096cd89994f
data/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
- ## [0.0.4] - 2025-03-06
1
+ ## [0.0.6] - 2025-05-21
2
2
 
3
- - Defer to downstream default model if none specifiedz
3
+ - Give an error if no ai api services have been provided
4
+
5
+ ## [0.0.5] - 2025-03-06
6
+
7
+ - Defer to downstream default model if none specified
8
+ - Split LlmBackedCommand into a Command and a LlmBackedExecuteMethod mixin
4
9
 
5
10
  ## [0.0.3] - 2025-03-05
6
11
 
data/README.md CHANGED
@@ -1,6 +1,22 @@
1
- # Foobara::LlmBackedCommand
1
+ # Foobara::LlmBackedCommand/Foobara::LlmBackedExecuteMethod
2
2
 
3
- Provides a clean and quick way to implement a Foobara::Command that has logic backed by an LLM.
3
+ Provides a clean and quick way to implement a Foobara::Command that defers to an LLM for the answer
4
+
5
+ <!-- TOC -->
6
+ * [Foobara::LlmBackedCommand/Foobara::LlmBackedExecuteMethod](#foobarallmbackedcommandfoobarallmbackedexecutemethod)
7
+ * [Installation](#installation)
8
+ * [Usage](#usage)
9
+ * [Choosing a different model and llm service](#choosing-a-different-model-and-llm-service)
10
+ * [Using it as a mixin instead of a class](#using-it-as-a-mixin-instead-of-a-class)
11
+ * [Typical Foobara stuff: exposing it on the command-line](#typical-foobara-stuff-exposing-it-on-the-command-line)
12
+ * [Exposing commands through Rack](#exposing-commands-through-rack)
13
+ * [Exposing commands through Rails](#exposing-commands-through-rails)
14
+ * [Use with models](#use-with-models)
15
+ * [Use with entities](#use-with-entities)
16
+ * [Complete scripts to play with](#complete-scripts-to-play-with)
17
+ * [Contributing](#contributing)
18
+ * [License](#license)
19
+ <!-- TOC -->
4
20
 
5
21
  ## Installation
6
22
 
@@ -9,12 +25,471 @@ Typical stuff: add `gem "foobara-llm-backed-command` to your Gemfile or .gemspec
9
25
 
10
26
  ## Usage
11
27
 
12
- TODO: Write usage instructions here
28
+ To play with these examples you can do `gem install foobara-llm-backed-command foobara-anthropic-api`
29
+ and then the following:
30
+
31
+ ```ruby
32
+ ENV["ANTHROPIC_API_KEY"] = "<your key here>"
33
+
34
+ require 'foobara/llm_backed_command'
35
+
36
+ class DetermineLanguage < Foobara::LlmBackedCommand
37
+ inputs code_snippet: :string
38
+ result most_likely: :string, probabilities: { ruby: :float, c: :float, smalltalk: :float, java: :float }
39
+ end
40
+
41
+ puts DetermineLanguage.run!(code_snippet: "puts 'Hello, World'")
42
+ ```
43
+
44
+ Running this script outputs:
45
+
46
+ ```
47
+ $ ./demo
48
+ {most_likely: "ruby", probabilities: {ruby: 0.95, c: 0.01, smalltalk: 0.02, java: 0.02}}
49
+ ```
50
+
51
+ Here we have only specified the command name and the inputs/result. Our LLM of choice
52
+ will be used to get the answer, and it will be structured according to the result type.
53
+
54
+ The default LLM model is claude-3-7-sonnet, but you can use others:
55
+
56
+ ### Choosing a different model and llm service
57
+
58
+ One way to do this is by creating an input called `llm_model`
59
+
60
+ ```ruby
61
+ ENV["ANTHROPIC_API_KEY"] = "<your key here>"
62
+ ENV["OPENAI_API_KEY"] = "<your key here>"
63
+ ENV["OLLAMA_API_URL"] = "<your ollama API if different than http://localhost:11434>"
64
+
65
+ require "foobara/anthropic_api"
66
+ require "foobara/open_ai_api"
67
+ require "foobara/ollama_api"
68
+ require 'foobara/llm_backed_command'
69
+
70
+ class DetermineLanguage < Foobara::LlmBackedCommand
71
+ inputs do
72
+ code_snippet :string, :required
73
+ llm_model Foobara::Ai::AnswerBot::Types.model_enum, default: "claude-3-7-sonnet-20250219"
74
+ end
75
+
76
+ result most_likely: :string, probabilities: { ruby: :float, c: :float, smalltalk: :float, java: :float }
77
+ end
78
+
79
+ inputs = { llm_model: "chat-gpt-3-5-turbo", code_snippet: "puts 'Hello, World'" }
80
+ command = DetermineLanguage.new(inputs)
81
+ outcome = command.run
82
+
83
+ puts outcome.success? ? outcome.result : outcome.errors_hash
84
+ ```
85
+
86
+ ### Using it as a mixin instead of a class
87
+
88
+ If you need the inheritance slot for some other command base class, you can use the mixin:
89
+
90
+ ```ruby
91
+ class DetermineLanguage < Foobara::Command
92
+ include Foobara::LlmBackedExecuteMethod
93
+
94
+ inputs code_snippet: :string
95
+ result most_likely: :string, probabilities: { ruby: :float, c: :float, smalltalk: :float, java: :float }
96
+ end
97
+ ```
98
+
99
+ ### Typical Foobara stuff: exposing it on the command-line
100
+
101
+ Probably best to refer to Foobara for details instead of turning this README.md into a Foobara tutorial, but
102
+ these can be used as any other Foobara command. So exposing
103
+ the command on the command line is easy (requires `gem install foobara-sh-cli-connector fooara-dotenv-loader`)
104
+ (switching to dotenv for convenience):
105
+
106
+ ```ruby
107
+ #!/usr/bin/env ruby
108
+
109
+ require "foobara/load_dotenv"
110
+ Foobara::LoadDotenv.run!(dir: __dir__)
111
+
112
+ require "foobara/anthropic_api"
113
+ require "foobara/open_ai_api"
114
+ require "foobara/ollama_api"
115
+ require "foobara/llm_backed_command"
116
+ require "foobara/sh_cli_connector"
117
+
118
+ class DetermineLanguage < Foobara::LlmBackedCommand
119
+ inputs do
120
+ code_snippet :string, :required
121
+ llm_model Foobara::Ai::AnswerBot::Types.model_enum, default: "claude-3-7-sonnet-20250219"
122
+ end
123
+ result most_likely: :string, probabilities: { ruby: :float, c: :float, smalltalk: :float, java: :float }
124
+ end
125
+
126
+ Foobara::CommandConnectors::ShCliConnector.new(single_command_mode: DetermineLanguage).run(ARGV)
127
+ ```
128
+
129
+ which allows:
130
+
131
+ ```
132
+ $ ./determine-language --help
133
+ Usage: determine-language [INPUTS]
134
+
135
+ Inputs:
136
+
137
+ -c, --code-snippet CODE_SNIPPET Required
138
+ -l, --llm-model LLM_MODEL One of: babbage-002, chatgpt-4o-latest, claude-2.0, claude-2.1,
139
+ claude-3-5-haiku-20241022, claude-3-5-sonnet-20240620, claude-3-5-sonnet-20241022,
140
+ claude-3-7-sonnet-20250219, claude-3-haiku-20240307, claude-3-opus-20240229,
141
+ claude-3-sonnet-20240229, dall-e-2, dall-e-3, davinci-002, gpt-3.5-turbo,
142
+ gpt-3.5-turbo-0125, gpt-3.5-turbo-1106, gpt-3.5-turbo-16k, gpt-3.5-turbo-instruct,
143
+ gpt-3.5-turbo-instruct-0914, gpt-4, gpt-4-0125-preview, gpt-4-0613,
144
+ gpt-4-1106-preview, gpt-4-turbo, gpt-4-turbo-2024-04-09, gpt-4-turbo-preview,
145
+ gpt-4.5-preview, gpt-4.5-preview-2025-02-27, gpt-4o, gpt-4o-2024-05-13,
146
+ gpt-4o-2024-08-06, gpt-4o-2024-11-20, gpt-4o-audio-preview, gpt-4o-audio-preview-2024-10-01,
147
+ gpt-4o-audio-preview-2024-12-17, gpt-4o-mini, gpt-4o-mini-2024-07-18,
148
+ gpt-4o-mini-audio-preview, gpt-4o-mini-audio-preview-2024-12-17,
149
+ gpt-4o-mini-realtime-preview, gpt-4o-mini-realtime-preview-2024-12-17,
150
+ gpt-4o-mini-search-preview, gpt-4o-mini-search-preview-2025-03-11,
151
+ gpt-4o-realtime-preview, gpt-4o-realtime-preview-2024-10-01, gpt-4o-realtime-preview-2024-12-17,
152
+ gpt-4o-search-preview, gpt-4o-search-preview-2025-03-11, llama3:8b,
153
+ o1, o1-2024-12-17, o1-mini, o1-mini-2024-09-12, o1-preview, o1-preview-2024-09-12,
154
+ o3-mini, o3-mini-2025-01-31, omni-moderation-2024-09-26, omni-moderation-latest,
155
+ smollm2:135m, text-embedding-3-large, text-embedding-3-small, text-embedding-ada-002,
156
+ tts-1, tts-1-1106, tts-1-hd, tts-1-hd-1106, whisper-1.
157
+ Default: "claude-3-7-sonnet-20250219"
158
+ ```
159
+
160
+ Running the program:
161
+
162
+ ```
163
+ $ ./determine-language --code-snippet "Transcript show: 'Hello, World'"
164
+ most_likely: "smalltalk",
165
+ probabilities: {
166
+ ruby: 0.1,
167
+ c: 0.05,
168
+ smalltalk: 0.8,
169
+ java: 0.05
170
+ }
171
+ ```
172
+
173
+ ### Exposing commands through Rack
174
+
175
+ Or you can spin up a quick json API either through Rack or the Rails router. Here's an example with the rack connector
176
+ (requires `gem install foobara-rack-controller rackup puma`):
177
+
178
+ ```ruby
179
+ #!/usr/bin/env ruby
180
+
181
+ require "foobara/load_dotenv"
182
+ Foobara::LoadDotenv.run!(dir: __dir__)
183
+
184
+ require "foobara/llm_backed_command"
185
+ require "foobara/rack_connector"
186
+ require "rackup/server"
187
+
188
+ class DetermineLanguage < Foobara::LlmBackedCommand
189
+ inputs code_snippet: :string
190
+ result probabilities: { ruby: :float, c: :float, smalltalk: :float, java: :float },
191
+ most_likely: :string
192
+ end
193
+
194
+ command_connector = Foobara::CommandConnectors::Http::Rack.new
195
+ command_connector.connect(DetermineLanguage)
196
+
197
+ Rackup::Server.start(app: command_connector)
198
+ ```
199
+
200
+ After we run this script we can hit it with curl:
201
+
202
+ ```
203
+ $ curl http://localhost:9292/run/DetermineLanguage?code_snippet=System.out.println
204
+ {"probabilities":{"ruby":0.05,"c":0.1,"smalltalk":0.05,"java":0.8},"most_likely":"java"}
205
+ ```
206
+
207
+ And we can run it from other systems in either Ruby or TypeScript.
208
+
209
+ In Ruby (requires `gem install foobara-remote-imports`):
210
+
211
+ ```ruby
212
+ #!/usr/bin/env ruby
213
+
214
+ require "foobara/remote_imports"
215
+
216
+ Foobara::RemoteImports::ImportCommand.run!(manifest_url: "http://localhost:9292/manifest", cache: true)
217
+
218
+ puts DetermineLanguage.run!(code_snippet: "System.out.println")
219
+ ```
220
+
221
+ This outputs:
222
+
223
+ ```
224
+ $ ./determine-language-client
225
+ {probabilities: {ruby: 0.05, c: 0.05, smalltalk: 0.1, java: 0.8}, most_likely: "java"}
226
+ ```
227
+
228
+ And we can also generate a remote command in TypeScript (requires `gem install foob`)
229
+
230
+ From inside a TypeScript project:
231
+
232
+ ```
233
+ $ foob g typescript-remote-commands --manifest-url http://localhost:9292/manifest
234
+ ```
235
+
236
+ This will generate code so that we can do:
237
+
238
+ ```TypeScript
239
+ import { DetermineLanguage } from "./DetermineLanguage"
240
+
241
+ const command = new DetermineLanguage({code_snippet: "System.out.println"})
242
+ const outcome = await command.run()
243
+
244
+ if (outcome.isSuccess) {
245
+ console.log(outcome.result)
246
+ } else {
247
+ console.error(outcome.errors_hash)
248
+ }
249
+ ```
250
+
251
+ and everything will be fully typed, inputs, result, etc.
252
+
253
+ ### Exposing commands through Rails
254
+
255
+ This has become too much of a Foobara tutorial so instead please refer to
256
+ https://github.com/foobara/rails-command-connector
257
+
258
+ ### Use with models
259
+
260
+ You can of course use this with whatever Foobara concepts you want, including models (and entities to some extent.)
261
+
262
+ Here's more complex example that accepts data encapsulated in model instances and returns model instances:
263
+
264
+ ```ruby
265
+ #!/usr/bin/env ruby
266
+
267
+ require "foobara/load_dotenv"
268
+
269
+ Foobara::LoadDotenv.run!(env: "development", dir: __dir__)
270
+
271
+ require "foobara/anthropic_api"
272
+ # require "foobara/open_ai_api"
273
+ # require "foobara/ollama_api"
274
+ require "foobara/llm_backed_command"
275
+
276
+ class PossibleUsState < Foobara::Model
277
+ attributes do
278
+ name :string, :required,
279
+ "A name that potentially might be a name of a US state, spelled correctly or incorrectly"
280
+ end
281
+ end
282
+
283
+ class VerifiedUsState < Foobara::Model
284
+ attributes do
285
+ possible_us_state PossibleUsState, :required,
286
+ "The original possible US state that was passed in"
287
+ spelling_correction_required :boolean, :required, "Whether or not the original spelling was correct"
288
+ corrected_spelling :string, :allow_nil,
289
+ "If the original spelling was incorrect, the corrected spelling will be here"
290
+ end
291
+ end
292
+
293
+ class SelectUsStateNamesAndCorrectTheirSpelling < Foobara::LlmBackedCommand
294
+ description <<~DESCRIPTION
295
+ Accepts a list of possible US state names and sorts them into verified to be the name of a
296
+ US state and rejected to be the name of a non-US state, as well as correcting the spelling of
297
+ the US state name if it's not correct.
298
+
299
+ example:
300
+
301
+ If you pass in ["Kalifornia", "Los Angeles", "New York"] the result will be:
302
+
303
+ result[:verified].length # => 2
304
+ result[:verified][0].possible_us_state.name # => "Kalifornia"
305
+ result[:verified][0].spelling_correction_required # => true#{" "}
306
+ result[:verified][0].corrected_spelling # => "California"
307
+ result[:verified][1].possible_us_state.name # => "New York"
308
+ result[:verified][1].spelling_correction_required # => false
309
+ result[:verified][1].corrected_spelling # => nil
310
+
311
+ result[:rejected].length # => 1
312
+ result[:rejected][0].name # => "Los Angeles"
313
+ DESCRIPTION
314
+
315
+ inputs do
316
+ list_of_possible_us_states [PossibleUsState]
317
+ llm_model :string, default: "claude-3-7-sonnet-20250219"
318
+ end
319
+
320
+ result do
321
+ verified [VerifiedUsState]
322
+ rejected [PossibleUsState]
323
+ end
324
+ end
325
+
326
+ list_of_possible_us_states = [
327
+ PossibleUsState.new(name: "Grand Rapids"),
328
+ PossibleUsState.new(name: "Oregon"),
329
+ PossibleUsState.new(name: "Yutah"),
330
+ PossibleUsState.new(name: "Misisipi"),
331
+ PossibleUsState.new(name: "Tacoma"),
332
+ PossibleUsState.new(name: "Kalifornia"),
333
+ PossibleUsState.new(name: "Los Angeles"),
334
+ PossibleUsState.new(name: "New York")
335
+ ]
336
+
337
+ command = SelectUsStateNamesAndCorrectTheirSpelling.new(list_of_possible_us_states:)
338
+ result = command.run!
339
+
340
+ puts "Considering:"
341
+ list_of_possible_us_states.each do |possible_us_state|
342
+ puts " #{possible_us_state.name}"
343
+ end
344
+ puts
345
+
346
+ puts "#{result[:verified].length} were verified as US states:"
347
+ result[:verified].each do |verified_us_state|
348
+ puts " #{verified_us_state.corrected_spelling || verified_us_state.possible_us_state.name}"
349
+ if verified_us_state.spelling_correction_required
350
+ puts " original incorrect spelling: #{verified_us_state.possible_us_state.name}"
351
+ end
352
+ end
353
+
354
+ puts
355
+ puts "#{result[:rejected].length} were rejected as non-US states:"
356
+ result[:rejected].each do |rejected_us_state|
357
+ puts " #{rejected_us_state.name}"
358
+ end
359
+ ```
360
+
361
+ This script outputs:
362
+
363
+ ```
364
+ Considering:
365
+ Grand Rapids
366
+ Oregon
367
+ Yutah
368
+ Misisipi
369
+ Tacoma
370
+ Kalifornia
371
+ Los Angeles
372
+ New York
373
+
374
+ 5 were verified as US states:
375
+ Oregon
376
+ Utah
377
+ original incorrect spelling: Yutah
378
+ Mississippi
379
+ original incorrect spelling: Misisipi
380
+ California
381
+ original incorrect spelling: Kalifornia
382
+ New York
383
+
384
+ 3 were rejected as non-US states:
385
+ Grand Rapids
386
+ Tacoma
387
+ Los Angeles
388
+ ```
389
+
390
+ Note that we were able to access model attributes by methods just fine, and we didn't write any logic on how
391
+ to spell check or any logic on how determine what is or is not a US state.
392
+
393
+ ### Use with entities
394
+
395
+ Unclear how practical using entities in this way would be, but, here's an interesting
396
+ example of asking a question about some persisted records:
397
+
398
+ ```ruby
399
+ #!/usr/bin/env ruby
400
+
401
+ require "foobara/load_dotenv"
402
+
403
+ Foobara::LoadDotenv.run!(env: "development", dir: __dir__)
404
+
405
+ require "foobara/llm_backed_command"
406
+ require "foobara/sh_cli_connector"
407
+ require "foobara/local_files_crud_driver"
408
+
409
+ Foobara::Persistence.default_crud_driver = Foobara::LocalFilesCrudDriver.new
410
+
411
+ class Capybara < Foobara::Entity
412
+ attributes do
413
+ id :integer
414
+ name :string, :required
415
+ age :integer, :required
416
+ end
417
+ primary_key :id
418
+ end
419
+
420
+ class CreateCapybara < Foobara::Command
421
+ inputs Capybara.attributes_for_create
422
+ result Capybara
423
+
424
+ def execute
425
+ create_capybara
426
+
427
+ capybara
428
+ end
429
+
430
+ attr_accessor :capybara
431
+
432
+ def create_capybara
433
+ self.capybara = Capybara.create(inputs)
434
+ end
435
+ end
436
+
437
+ class SelectOldestCapybara < Foobara::LlmBackedCommand
438
+ inputs capybaras: [Capybara]
439
+ result Capybara
440
+ end
441
+
442
+ connector = Foobara::CommandConnectors::ShCliConnector.new
443
+
444
+ connector.connect(CreateCapybara)
445
+ connector.connect(SelectOldestCapybara)
446
+
447
+ connector.run(ARGV)
448
+ ```
449
+
450
+ We can create some capybara records like this:
451
+
452
+ ```
453
+ $ ./oldest-capybara-example CreateCapybara --age 100 --name Fumiko
454
+ age: 100,
455
+ name: "Fumiko",
456
+ id: 1
457
+ $ ./oldest-capybara-example CreateCapybara --age 200 --name Barbara
458
+ age: 200,
459
+ name: "Barbara",
460
+ id: 2
461
+ $ ./oldest-capybara-example CreateCapybara --age 300 --name Basil
462
+ age: 300,
463
+ name: "Basil",
464
+ id: 3
465
+ ```
466
+
467
+ And then ask who is older, Barbara or Basil, like so:
468
+
469
+ ```
470
+ $ ./oldest-capybara-example SelectOldestCapybara --capybaras 2 3
471
+ id: 3,
472
+ name: "Basil",
473
+ age: 300
474
+ ```
475
+
476
+ Basil is older. Pretty crazy we didn't have to write any logic about age comparisons at all.
477
+ And our SelectOldestCapybara command is only 4 lines long as a result.
478
+
479
+ ### Complete scripts to play with
480
+
481
+ There are various scripts in [example_scripts/shorter](example_scripts/shorter)
482
+ and [example_scripts/higher_quality](example_scripts/higher_quality) that you can play with.
483
+
484
+ The difference between the two directories is that `higher_quality/` contains scripts that are more realistic
485
+ with more descriptions and comments and `shorter/` focuses on keeping the scripts short and simple.
486
+
487
+ Those directories also have a Gemfile each if helpful for pulling in dependencies
488
+ and so that you can use `bundle exec` there if needed.
13
489
 
14
490
  ## Contributing
15
491
 
16
- Bug reports and pull requests are welcome on GitHub
17
- at https://github.com/foobara/llm-backed-command
492
+ Bug reports and pull requests are welcome on GitHub at https://github.com/foobara/llm-backed-command
18
493
 
19
494
  ## License
20
495
 
@@ -4,6 +4,13 @@ require "foobara/command_connectors"
4
4
  require "foobara/ai"
5
5
  require "foobara/json_schema_generator"
6
6
 
7
+ if Foobara::Ai.foobara_all_command.empty?
8
+ # :nocov:
9
+ raise "No api services loaded. " \
10
+ "Did you forget to set a URL/API key env var or a require for either ollama, anthropic, or openai?"
11
+ # :nocov:
12
+ end
13
+
7
14
  Foobara::Util.require_directory "#{__dir__}/../../src"
8
15
 
9
16
  Foobara::Monorepo.project "llm_backed_command", project_path: "#{__dir__}/../../"
@@ -1,176 +1,5 @@
1
- # NOTE: You can add the following inputs if you'd like, or, create methods with these names
2
- # on the class.
3
- #
4
- # inputs do
5
- # association_depth :symbol, one_of: JsonSchemaGenerator::AssociationDepth, default: AssociationDepth::ATOM
6
- # llm_model :symbol, one_of: Foobara::Ai::AnswerBot::Types::ModelEnum
7
- # end
8
1
  module Foobara
9
- module LlmBackedCommand
10
- include Concern
11
-
12
- on_include do
13
- depends_on Ai::AnswerBot::Ask
14
- end
15
-
16
- def execute
17
- determine_serializer
18
- construct_input_json
19
- generate_answer
20
- parse_answer
21
-
22
- parsed_answer
23
- end
24
-
25
- attr_accessor :serializer, :input_json, :answer, :parsed_answer
26
-
27
- def determine_serializer
28
- depth = if respond_to?(:association_depth)
29
- association_depth
30
- else
31
- Foobara::JsonSchemaGenerator::AssociationDepth::AGGREGATE
32
- end
33
-
34
- serializer = case depth
35
- when Foobara::JsonSchemaGenerator::AssociationDepth::ATOM
36
- Foobara::CommandConnectors::Serializers::AtomicSerializer
37
- when Foobara::JsonSchemaGenerator::AssociationDepth::AGGREGATE
38
- Foobara::CommandConnectors::Serializers::AggregateSerializer
39
- when Foobara::JsonSchemaGenerator::AssociationDepth::PRIMARY_KEY_ONLY
40
- # :nocov:
41
- raise "PRIMARY_KEY_ONLY depth not yet implemented"
42
- # :nocov:
43
- else
44
- # :nocov:
45
- raise "Unknown depth: #{depth}"
46
- # :nocov:
47
- end
48
-
49
- # cache this?
50
- self.serializer = serializer.new
51
- end
52
-
53
- def construct_input_json
54
- inputs_without_llm_integration_inputs = inputs.except(:llm_model, :association_depth)
55
- input_json = serializer.serialize(inputs_without_llm_integration_inputs)
56
-
57
- self.input_json = JSON.fast_generate(input_json)
58
- end
59
-
60
- def generate_answer
61
- ask_inputs = {
62
- instructions: llm_instructions,
63
- question: input_json
64
- }
65
-
66
- if respond_to?(:llm_model)
67
- ask_inputs[:model] = llm_model
68
- end
69
-
70
- self.answer = run_subcommand!(Ai::AnswerBot::Ask, ask_inputs)
71
- end
72
-
73
- def llm_instructions
74
- self.class.llm_instructions
75
- end
76
-
77
- def parse_answer
78
- stripped_answer = answer.gsub(/<THINK>.*?<\/THINK>/mi, "")
79
- fencepostless_answer = stripped_answer.gsub(/^\s*```\w*\n(.*)```\s*\z/m, "\\1")
80
- # TODO: should we verify against json-schema or no?
81
- self.parsed_answer = begin
82
- JSON.parse(fencepostless_answer)
83
- rescue => e
84
- # see if we can extract the last fence-posts content just in case
85
- last_fence_post_regex = /```\w*\s*\n((?:(?!```).)+)\n```(?:(?!```).)*\z/m
86
- begin
87
- match = last_fence_post_regex.match(stripped_answer)
88
- if match
89
- JSON.parse(match[1])
90
- else
91
- # :nocov:
92
- raise e
93
- # :nocov:
94
- end
95
- rescue
96
- # :nocov:
97
- raise e
98
- # :nocov:
99
- end
100
- end
101
- end
102
-
103
- module ClassMethods
104
- def inputs_json_schema
105
- @inputs_json_schema ||= JsonSchemaGenerator.to_json_schema(inputs_type_without_llm_integration_inputs)
106
- end
107
-
108
- def inputs_type_without_llm_integration_inputs
109
- return @inputs_type_without_llm_integration_inputs if @inputs_type_without_llm_integration_inputs
110
-
111
- type_declaration = Util.deep_dup(inputs_type.declaration_data)
112
-
113
- element_type_declarations = type_declaration[:element_type_declarations]
114
-
115
- changed = false
116
-
117
- if element_type_declarations.key?(:llm_model)
118
- changed = true
119
- element_type_declarations.delete(:llm_model)
120
- end
121
-
122
- if element_type_declarations.key?(:association_depth)
123
- changed = true
124
- element_type_declarations.delete(:association_depth)
125
- end
126
-
127
- if type_declaration.key?(:defaults)
128
- if type_declaration[:defaults].key?(:llm_model)
129
- changed = true
130
- type_declaration[:defaults].delete(:llm_model)
131
- end
132
-
133
- if type_declaration[:defaults].key?(:association_depth)
134
- changed = true
135
- type_declaration[:defaults].delete(:association_depth)
136
- end
137
- if type_declaration[:defaults].empty?
138
- type_declaration.delete(:defaults)
139
- end
140
- end
141
-
142
- @inputs_type_without_llm_integration_inputs = if changed
143
- domain.foobara_type_from_declaration(type_declaration)
144
- else
145
- inputs_type
146
- end
147
- end
148
-
149
- def result_json_schema
150
- @result_json_schema ||= JsonSchemaGenerator.to_json_schema(result_type)
151
- end
152
-
153
- def llm_instructions
154
- @llm_instructions ||= <<~INSTRUCTIONS
155
- You are implementing an API for a command named #{scoped_full_name} which has the following description:
156
-
157
- #{description}#{" "}
158
-
159
- Here is the inputs JSON schema for the data you will receive:
160
-
161
- #{inputs_json_schema}
162
-
163
- Here is the result JSON schema:
164
-
165
- #{result_json_schema}
166
-
167
- You will receive 1 message containing only JSON data according to the inputs JSON schema above
168
- and you will generate a JSON response that is a valid response according to the result JSON schema above.
169
-
170
- You will reply with nothing more than the JSON you've generated so that the calling code
171
- can successfully parse your answer.
172
- INSTRUCTIONS
173
- end
174
- end
2
+ class LlmBackedCommand < Foobara::Command
3
+ include LlmBackedExecuteMethod
175
4
  end
176
5
  end
@@ -0,0 +1,176 @@
1
+ # NOTE: You can add the following inputs if you'd like, or, create methods with these names
2
+ # on the class.
3
+ #
4
+ # inputs do
5
+ # association_depth :symbol, one_of: JsonSchemaGenerator::AssociationDepth, default: AssociationDepth::ATOM
6
+ # llm_model :symbol, one_of: Foobara::Ai::AnswerBot::Types::ModelEnum
7
+ # end
8
+ module Foobara
9
+ module LlmBackedExecuteMethod
10
+ include Concern
11
+
12
+ on_include do
13
+ depends_on Ai::AnswerBot::Ask
14
+ end
15
+
16
+ def execute
17
+ determine_serializer
18
+ construct_input_json
19
+ generate_answer
20
+ parse_answer
21
+
22
+ parsed_answer
23
+ end
24
+
25
+ attr_accessor :serializer, :input_json, :answer, :parsed_answer
26
+
27
+ def determine_serializer
28
+ depth = if respond_to?(:association_depth)
29
+ association_depth
30
+ else
31
+ Foobara::JsonSchemaGenerator::AssociationDepth::AGGREGATE
32
+ end
33
+
34
+ serializer = case depth
35
+ when Foobara::JsonSchemaGenerator::AssociationDepth::ATOM
36
+ Foobara::CommandConnectors::Serializers::AtomicSerializer
37
+ when Foobara::JsonSchemaGenerator::AssociationDepth::AGGREGATE
38
+ Foobara::CommandConnectors::Serializers::AggregateSerializer
39
+ when Foobara::JsonSchemaGenerator::AssociationDepth::PRIMARY_KEY_ONLY
40
+ # :nocov:
41
+ raise "PRIMARY_KEY_ONLY depth not yet implemented"
42
+ # :nocov:
43
+ else
44
+ # :nocov:
45
+ raise "Unknown depth: #{depth}"
46
+ # :nocov:
47
+ end
48
+
49
+ # cache this?
50
+ self.serializer = serializer.new
51
+ end
52
+
53
+ def construct_input_json
54
+ inputs_without_llm_integration_inputs = inputs.except(:llm_model, :association_depth)
55
+ input_json = serializer.serialize(inputs_without_llm_integration_inputs)
56
+
57
+ self.input_json = JSON.fast_generate(input_json)
58
+ end
59
+
60
+ def generate_answer
61
+ ask_inputs = {
62
+ instructions: llm_instructions,
63
+ question: input_json
64
+ }
65
+
66
+ if respond_to?(:llm_model)
67
+ ask_inputs[:model] = llm_model
68
+ end
69
+
70
+ self.answer = run_subcommand!(Ai::AnswerBot::Ask, ask_inputs)
71
+ end
72
+
73
+ def llm_instructions
74
+ self.class.llm_instructions
75
+ end
76
+
77
+ def parse_answer
78
+ stripped_answer = answer.gsub(/<THINK>.*?<\/THINK>/mi, "")
79
+ fencepostless_answer = stripped_answer.gsub(/^\s*```\w*\n(.*)```\s*\z/m, "\\1")
80
+ # TODO: should we verify against json-schema or no?
81
+ self.parsed_answer = begin
82
+ JSON.parse(fencepostless_answer)
83
+ rescue => e
84
+ # see if we can extract the last fence-posts content just in case
85
+ last_fence_post_regex = /```\w*\s*\n((?:(?!```).)+)\n```(?:(?!```).)*\z/m
86
+ begin
87
+ match = last_fence_post_regex.match(stripped_answer)
88
+ if match
89
+ JSON.parse(match[1])
90
+ else
91
+ # :nocov:
92
+ raise e
93
+ # :nocov:
94
+ end
95
+ rescue
96
+ # :nocov:
97
+ raise e
98
+ # :nocov:
99
+ end
100
+ end
101
+ end
102
+
103
+ module ClassMethods
104
+ def inputs_json_schema
105
+ @inputs_json_schema ||= JsonSchemaGenerator.to_json_schema(inputs_type_without_llm_integration_inputs)
106
+ end
107
+
108
+ def inputs_type_without_llm_integration_inputs
109
+ return @inputs_type_without_llm_integration_inputs if @inputs_type_without_llm_integration_inputs
110
+
111
+ type_declaration = Util.deep_dup(inputs_type.declaration_data)
112
+
113
+ element_type_declarations = type_declaration[:element_type_declarations]
114
+
115
+ changed = false
116
+
117
+ if element_type_declarations.key?(:llm_model)
118
+ changed = true
119
+ element_type_declarations.delete(:llm_model)
120
+ end
121
+
122
+ if element_type_declarations.key?(:association_depth)
123
+ changed = true
124
+ element_type_declarations.delete(:association_depth)
125
+ end
126
+
127
+ if type_declaration.key?(:defaults)
128
+ if type_declaration[:defaults].key?(:llm_model)
129
+ changed = true
130
+ type_declaration[:defaults].delete(:llm_model)
131
+ end
132
+
133
+ if type_declaration[:defaults].key?(:association_depth)
134
+ changed = true
135
+ type_declaration[:defaults].delete(:association_depth)
136
+ end
137
+ if type_declaration[:defaults].empty?
138
+ type_declaration.delete(:defaults)
139
+ end
140
+ end
141
+
142
+ @inputs_type_without_llm_integration_inputs = if changed
143
+ domain.foobara_type_from_declaration(type_declaration)
144
+ else
145
+ inputs_type
146
+ end
147
+ end
148
+
149
+ def result_json_schema
150
+ @result_json_schema ||= JsonSchemaGenerator.to_json_schema(result_type)
151
+ end
152
+
153
+ def llm_instructions
154
+ @llm_instructions ||= <<~INSTRUCTIONS
155
+ You are implementing an API for a command named #{scoped_full_name} which has the following description:
156
+
157
+ #{description}#{" "}
158
+
159
+ Here is the inputs JSON schema for the data you will receive:
160
+
161
+ #{inputs_json_schema}
162
+
163
+ Here is the result JSON schema:
164
+
165
+ #{result_json_schema}
166
+
167
+ You will receive 1 message containing only JSON data according to the inputs JSON schema above
168
+ and you will generate a JSON response that is a valid response according to the result JSON schema above.
169
+
170
+ You will reply with nothing more than the JSON you've generated so that the calling code
171
+ can successfully parse your answer.
172
+ INSTRUCTIONS
173
+ end
174
+ end
175
+ end
176
+ end
metadata CHANGED
@@ -1,42 +1,56 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foobara-llm-backed-command
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miles Georgi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-06 00:00:00.000000000 Z
10
+ date: 2025-05-21 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: foobara
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.0.92
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.0.92
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: foobara-ai
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
- - - ">="
30
+ - - "~>"
17
31
  - !ruby/object:Gem::Version
18
- version: '0'
32
+ version: 0.0.1
19
33
  type: :runtime
20
34
  prerelease: false
21
35
  version_requirements: !ruby/object:Gem::Requirement
22
36
  requirements:
23
- - - ">="
37
+ - - "~>"
24
38
  - !ruby/object:Gem::Version
25
- version: '0'
39
+ version: 0.0.1
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: foobara-json-schema-generator
28
42
  requirement: !ruby/object:Gem::Requirement
29
43
  requirements:
30
- - - ">="
44
+ - - "~>"
31
45
  - !ruby/object:Gem::Version
32
- version: '0'
46
+ version: 0.0.1
33
47
  type: :runtime
34
48
  prerelease: false
35
49
  version_requirements: !ruby/object:Gem::Requirement
36
50
  requirements:
37
- - - ">="
51
+ - - "~>"
38
52
  - !ruby/object:Gem::Version
39
- version: '0'
53
+ version: 0.0.1
40
54
  email:
41
55
  - azimux@gmail.com
42
56
  executables: []
@@ -49,6 +63,7 @@ files:
49
63
  - README.md
50
64
  - lib/foobara/llm_backed_command.rb
51
65
  - src/llm_backed_command.rb
66
+ - src/llm_backed_execute_method.rb
52
67
  homepage: https://github.com/foobara/llm-backed-command
53
68
  licenses:
54
69
  - MPL-2.0
@@ -71,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
71
86
  - !ruby/object:Gem::Version
72
87
  version: '0'
73
88
  requirements: []
74
- rubygems_version: 3.6.5
89
+ rubygems_version: 3.6.2
75
90
  specification_version: 4
76
91
  summary: Provides an easy way to implement a command whose logic is managed by an
77
92
  LLM