Skip to content

Commit

Permalink
adds Predicate module and bump to 0.4.3
Browse files Browse the repository at this point in the history
  • Loading branch information
obie committed Nov 11, 2024
1 parent cb6f681 commit dcdde7f
Show file tree
Hide file tree
Showing 20 changed files with 1,854 additions and 5 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@

## [0.4.2] - 2024-11-05
- adds support for [Predicted Outputs](https://platform.openai.com/docs/guides/latency-optimization#use-predicted-outputs) with the `prediction` option for OpenAI

## [0.4.3] - 2024-11-11
- adds support for `Predicate` module
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ group :development do
gem "sorbet"
gem "tapioca", require: false
end

group :test do
gem "vcr"
gem "webmock"
end
15 changes: 14 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
raix (0.4.2)
raix (0.4.3)
activesupport (>= 6.0)
open_router (~> 0.2)

Expand All @@ -18,6 +18,8 @@ GEM
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
backport (1.2.0)
base64 (0.2.0)
Expand All @@ -26,6 +28,8 @@ GEM
coderay (1.1.3)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
crack (0.4.5)
rexml
diff-lcs (1.5.1)
dotenv (3.1.2)
drb (2.2.1)
Expand Down Expand Up @@ -57,6 +61,7 @@ GEM
guard (~> 2.1)
guard-compat (~> 1.1)
rspec (>= 2.99.0, < 4.0)
hashdiff (1.0.1)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
jaro_winkler (1.6.0)
Expand Down Expand Up @@ -98,6 +103,7 @@ GEM
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
public_suffix (5.0.5)
racc (1.7.3)
rainbow (3.1.1)
rake (13.2.0)
Expand Down Expand Up @@ -191,6 +197,11 @@ GEM
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
uri (0.13.0)
vcr (6.2.0)
webmock (3.18.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
yard (0.9.36)
yard-sorbet (0.8.1)
sorbet-runtime (>= 0.5)
Expand All @@ -217,6 +228,8 @@ DEPENDENCIES
solargraph-rails (~> 0.2.0.pre)
sorbet
tapioca
vcr
webmock

BUNDLED WITH
2.4.12
70 changes: 70 additions & 0 deletions Guardfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# A sample Guardfile
# More info at https://github.com/guard/guard#readme

## Uncomment and set this to only include directories you want to watch
# directories %w(app lib config test spec features) \
# .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")}

## Note: if you are using the `directories` clause above and you are not
## watching the project directory ('.'), then you will want to move
## the Guardfile to a watched dir and symlink it back, e.g.
#
# $ mkdir config
# $ mv Guardfile config/
# $ ln -s config/Guardfile .
#
# and, you'll have to watch "config/Guardfile" instead of "Guardfile"

# Note: The cmd option is now required due to the increasing number of ways
# rspec may be run, below are examples of the most common uses.
# * bundler: 'bundle exec rspec'
# * bundler binstubs: 'bin/rspec'
# * spring: 'bin/rspec' (This will use spring if running and you have
# installed the spring binstubs per the docs)
# * zeus: 'zeus rspec' (requires the server to be started separately)
# * 'just' rspec: 'rspec'

guard :rspec, cmd: "bundle exec rspec" do
require "guard/rspec/dsl"
dsl = Guard::RSpec::Dsl.new(self)

# Feel free to open issues for suggestions and improvements

# RSpec files
rspec = dsl.rspec
watch(rspec.spec_helper) { rspec.spec_dir }
watch(rspec.spec_support) { rspec.spec_dir }
watch(rspec.spec_files)

# Ruby files
ruby = dsl.ruby
dsl.watch_spec_files_for(ruby.lib_files)

# Rails files
rails = dsl.rails(view_extensions: %w(erb haml slim))
dsl.watch_spec_files_for(rails.app_files)
dsl.watch_spec_files_for(rails.views)

watch(rails.controllers) do |m|
[
rspec.spec.call("routing/#{m[1]}_routing"),
rspec.spec.call("controllers/#{m[1]}_controller"),
rspec.spec.call("acceptance/#{m[1]}")
]
end

# Rails config changes
watch(rails.spec_helper) { rspec.spec_dir }
watch(rails.routes) { "#{rspec.spec_dir}/routing" }
watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }

# Capybara features specs
watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") }
watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") }

# Turnip features and steps
watch(%r{^spec/acceptance/(.+)\.feature$})
watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
end
end
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,77 @@ Notably, Olympia does not use the `FunctionDispatch` module in its primary conve

Streaming of the AI's response to the end user is handled by the `ReplyStream` class, passed to the final prompt declaration as its `stream` parameter. [Patterns of Application Development Using AI](https://leanpub.com/patterns-of-application-development-using-ai) devotes a whole chapter to describing how to write your own `ReplyStream` class.

## Predicate Module

The `Raix::Predicate` module provides a simple way to handle yes/no/maybe questions using AI chat completion. It allows you to define blocks that handle different types of responses with their explanations. It is one of the concrete patterns described in the "Discrete Components" chapter of [Patterns of Application Development Using AI](https://leanpub.com/patterns-of-application-development-using-ai).

### Usage

Include the `Raix::Predicate` module in your class and define handlers using block syntax:

```ruby
class Question
include Raix::Predicate

yes? do |explanation|
puts "Affirmative: #{explanation}"
end

no? do |explanation|
puts "Negative: #{explanation}"
end

maybe? do |explanation|
puts "Uncertain: #{explanation}"
end
end

question = Question.new
question.ask("Is Ruby a programming language?")
# => Affirmative: Yes, Ruby is a dynamic, object-oriented programming language...
```

### Features

- Define handlers for yes, no, and/or maybe responses using the declarative class level block syntax.
- At least one handler (yes, no, or maybe) must be defined.
- Handlers receive the full AI response including explanation as an argument.
- Responses always start with "Yes, ", "No, ", or "Maybe, " followed by an explanation.
- Make sure to ask a question that can be answered with yes, no, or maybe (otherwise the results are indeterminate).

### Example with Single Handler

You can define only the handlers you need:

```ruby
class SimpleQuestion
include Raix::Predicate

# Only handle positive responses
yes? do |explanation|
puts "#{explanation}"
end
end

question = SimpleQuestion.new
question.ask("Is 2 + 2 = 4?")
# => ✅ Yes, 2 + 2 equals 4, this is a fundamental mathematical fact.
```

### Error Handling

The module will raise a RuntimeError if you attempt to ask a question without defining any response handlers:

```ruby
class InvalidQuestion
include Raix::Predicate
end

question = InvalidQuestion.new
question.ask("Any question")
# => RuntimeError: Please define a yes and/or no block
```

## Installation

Install the gem and add to the application's Gemfile by executing:
Expand Down
64 changes: 64 additions & 0 deletions lib/raix/predicate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module Raix
# A module for handling yes/no questions using AI chat completion.
# When included in a class, it provides methods to define handlers for
# yes and no responses.
#
# @example
# class Question
# include Raix::Predicate
#
# yes do |explanation|
# puts "Yes: #{explanation}"
# end
#
# no do |explanation|
# puts "No: #{explanation}"
# end
# end
#
# question = Question.new
# question.ask("Is Ruby a programming language?")
module Predicate
include ChatCompletion

def self.included(base)
base.extend(ClassMethods)
end

def ask(question)
raise "Please define a yes and/or no block" if self.class.yes_block.nil? && self.class.no_block.nil?

transcript << { system: "Always answer 'Yes, ', 'No, ', or 'Maybe, ' followed by a concise explanation!" }
transcript << { user: question }

chat_completion.tap do |response|
if response.downcase.start_with?("yes,")
instance_exec(response, &self.class.yes_block) if self.class.yes_block
elsif response.downcase.start_with?("no,")
instance_exec(response, &self.class.no_block) if self.class.no_block
elsif response.downcase.start_with?("maybe,")
instance_exec(response, &self.class.maybe_block) if self.class.maybe_block
end
end
end

# Class methods added to the including class
module ClassMethods
attr_reader :yes_block, :no_block, :maybe_block

def yes?(&block)
@yes_block = block
end

def no?(&block)
@no_block = block
end

def maybe?(&block)
@maybe_block = block
end
end
end
end
2 changes: 1 addition & 1 deletion lib/raix/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Raix
VERSION = "0.4.2"
VERSION = "0.4.3"
end
2 changes: 1 addition & 1 deletion spec/raix/chat_completion_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def initialize
end
end

RSpec.describe MeaningOfLife do
RSpec.describe MeaningOfLife, :vcr do
subject { described_class.new }

it "does a completion with OpenAI" do
Expand Down
2 changes: 1 addition & 1 deletion spec/raix/function_dispatch_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def initialize
end
end

RSpec.describe WhatIsTheWeather do
RSpec.describe WhatIsTheWeather, :vcr do
subject { described_class.new }

it "can call a function and loop to provide text response" do
Expand Down
51 changes: 51 additions & 0 deletions spec/raix/predicate_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require "raix/predicate"

class Question
include Raix::Predicate

yes? do |explanation|
@callback.call(:yes, explanation)
end

no? do |explanation|
@callback.call(:no, explanation)
end

maybe? do |explanation|
@callback.call(:maybe, explanation)
end

def initialize(callback)
@callback = callback
end
end

class QuestionWithNoBlocks
include Raix::Predicate
end

RSpec.describe Raix::Predicate, :vcr do
let(:callback) { double("callback") }
let(:question) { Question.new(callback) }

it "yes" do
expect(callback).to receive(:call).with(:yes, "Yes, Ruby on Rails is a web application framework.")
question.ask("Is Ruby on Rails a web application framework?")
end

it "no" do
expect(callback).to receive(:call).with(:no, "No, the Eiffel Tower is located in Paris, France, not Madrid, Spain.")
question.ask("Is the Eiffel Tower in Madrid?")
end

it "maybe" do
expect(callback).to receive(:call).with(:maybe, "Maybe, it depends on the specific situation and context.")
question.ask("Should I quit my job?")
end

it "raises an error if no blocks are defined" do
expect { QuestionWithNoBlocks.new.ask("Is Ruby on Rails a web application framework?") }.to raise_error(RuntimeError, "Please define a yes and/or no block")
end
end
2 changes: 1 addition & 1 deletion spec/raix/prompt_caching_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def initialize
end
end

RSpec.describe GettingRealAnthropic do
RSpec.describe GettingRealAnthropic, :vcr do
subject { described_class.new }

it "does a completion with prompt caching" do
Expand Down
Loading

0 comments on commit dcdde7f

Please sign in to comment.