roast-ai 0.1.0

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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.json +12 -0
  3. data/.github/workflows/ci.yaml +29 -0
  4. data/.github/workflows/cla.yml +22 -0
  5. data/.gitignore +13 -0
  6. data/.rspec +1 -0
  7. data/.rubocop.yml +12 -0
  8. data/.ruby-version +1 -0
  9. data/CHANGELOG.md +0 -0
  10. data/CLAUDE.md +31 -0
  11. data/CODE_OF_CONDUCT.md +133 -0
  12. data/CONTRIBUTING.md +35 -0
  13. data/Gemfile +19 -0
  14. data/Gemfile.lock +194 -0
  15. data/LICENSE.md +21 -0
  16. data/README.md +27 -0
  17. data/Rakefile +24 -0
  18. data/bin/console +11 -0
  19. data/examples/grading/analyze_coverage/prompt.md +52 -0
  20. data/examples/grading/calculate_final_grade.rb +67 -0
  21. data/examples/grading/format_result.rb +48 -0
  22. data/examples/grading/generate_grades/prompt.md +105 -0
  23. data/examples/grading/generate_recommendations/output.txt +17 -0
  24. data/examples/grading/generate_recommendations/prompt.md +60 -0
  25. data/examples/grading/run_coverage.rb +47 -0
  26. data/examples/grading/verify_mocks_and_stubs/prompt.md +12 -0
  27. data/examples/grading/verify_test_helpers/prompt.md +53 -0
  28. data/examples/grading/workflow.md +8 -0
  29. data/examples/grading/workflow.rb.md +6 -0
  30. data/examples/grading/workflow.ts+tsx.md +6 -0
  31. data/examples/grading/workflow.yml +46 -0
  32. data/exe/roast +17 -0
  33. data/lib/roast/helpers/function_caching_interceptor.rb +27 -0
  34. data/lib/roast/helpers/logger.rb +104 -0
  35. data/lib/roast/helpers/minitest_coverage_runner.rb +244 -0
  36. data/lib/roast/helpers/path_resolver.rb +148 -0
  37. data/lib/roast/helpers/prompt_loader.rb +97 -0
  38. data/lib/roast/helpers.rb +12 -0
  39. data/lib/roast/tools/cmd.rb +72 -0
  40. data/lib/roast/tools/grep.rb +43 -0
  41. data/lib/roast/tools/read_file.rb +49 -0
  42. data/lib/roast/tools/search_file.rb +51 -0
  43. data/lib/roast/tools/write_file.rb +60 -0
  44. data/lib/roast/tools.rb +50 -0
  45. data/lib/roast/version.rb +5 -0
  46. data/lib/roast/workflow/base_step.rb +94 -0
  47. data/lib/roast/workflow/base_workflow.rb +79 -0
  48. data/lib/roast/workflow/configuration.rb +117 -0
  49. data/lib/roast/workflow/configuration_parser.rb +92 -0
  50. data/lib/roast/workflow/validator.rb +37 -0
  51. data/lib/roast/workflow/workflow_executor.rb +119 -0
  52. data/lib/roast/workflow.rb +13 -0
  53. data/lib/roast.rb +40 -0
  54. data/roast.gemspec +44 -0
  55. data/schema/workflow.json +92 -0
  56. data/shipit.rubygems.yml +0 -0
  57. metadata +171 -0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/helpers/logger"
4
+
5
+ module Roast
6
+ module Tools
7
+ module SearchFile
8
+ extend self
9
+
10
+ class << self
11
+ # Add this method to be included in other classes
12
+ def included(base)
13
+ base.class_eval do
14
+ function(
15
+ :search_for_file,
16
+ 'Search for a file in the project using `find . -type f -path "*#{@file_name}*"` in the project root',
17
+ name: { type: "string", description: "filename with as much of the path as you can deduce" },
18
+ ) do |params|
19
+ Roast::Tools::SearchFile.call(params[:name]).tap do |result|
20
+ Roast::Helpers::Logger.debug(result) if ENV["DEBUG"]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ def call(filename)
28
+ Roast::Helpers::Logger.info("🔍 Searching for file: #{filename}\n")
29
+ search_for(filename).then do |results|
30
+ return "No results found for #{filename}" if results.empty?
31
+ return Roast::Tools::ReadFile.call(results.first) if results.size == 1
32
+
33
+ results.inspect # purposely give the AI list of actual paths so that it can read without searching first
34
+ end
35
+ rescue StandardError => e
36
+ "Error searching for file: #{e.message}".tap do |error_message|
37
+ Roast::Helpers::Logger.error(error_message + "\n")
38
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
39
+ end
40
+ end
41
+
42
+ def search_for(filename)
43
+ # Execute find command and get the output using -path to match against full paths
44
+ result = %x(find . -type f -path "*#{filename}*").strip
45
+
46
+ # Split by newlines and get the first result
47
+ result.split("\n").map(&:strip).reject(&:empty?).map { |path| path.sub(%r{^\./}, "") }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "roast/helpers/logger"
5
+
6
+ module Roast
7
+ module Tools
8
+ module WriteFile
9
+ extend self
10
+
11
+ class << self
12
+ # Add this method to be included in other classes
13
+ def included(base)
14
+ base.class_eval do
15
+ function(
16
+ :write_file,
17
+ "Write content to a file. Creates the file if it does not exist, or overwrites it if it does.",
18
+ path: {
19
+ type: "string",
20
+ description: "The path to the file to write, relative to the current working directory: #{Dir.pwd}",
21
+ },
22
+ content: { type: "string", description: "The content to write to the file" },
23
+ ) do |params|
24
+ Roast::Tools::WriteFile.call(params[:path], params[:content]).tap do |_result|
25
+ Roast::Helpers::Logger.info(params[:content])
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ def call(path, content)
33
+ if path.start_with?("test/")
34
+
35
+ Roast::Helpers::Logger.info("📝 Writing to file: #{path}\n")
36
+
37
+ # Ensure the directory exists
38
+ dir = File.dirname(path)
39
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
40
+
41
+ # Write the content to the file
42
+ # Check if path is absolute or relative
43
+ absolute_path = path.start_with?("/") ? path : File.join(Dir.pwd, path)
44
+
45
+ File.write(absolute_path, content)
46
+
47
+ "Successfully wrote #{content.lines.count} lines to #{path}"
48
+ else
49
+ Roast::Helpers::Logger.error("😳 Path must start with 'test/' to use the write_file tool\n")
50
+ "Error: Path must start with 'test/' to use the write_file tool, try again."
51
+ end
52
+ rescue StandardError => e
53
+ "Error writing file: #{e.message}".tap do |error_message|
54
+ Roast::Helpers::Logger.error(error_message + "\n")
55
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/cache"
4
+ require "English"
5
+
6
+ require "roast/tools/grep"
7
+ require "roast/tools/read_file"
8
+ require "roast/tools/search_file"
9
+ require "roast/tools/write_file"
10
+ require "roast/tools/cmd"
11
+
12
+ module Roast
13
+ module Tools
14
+ extend self
15
+
16
+ CACHE = ActiveSupport::Cache::FileStore.new(File.join(Dir.pwd, ".roast", "cache"))
17
+
18
+ def file_to_prompt(file)
19
+ <<~PROMPT
20
+ # #{file}
21
+
22
+ #{File.read(file)}
23
+ PROMPT
24
+ rescue StandardError => e
25
+ Roast::Helpers::Logger.error("In current directory: #{Dir.pwd}\n")
26
+ Roast::Helpers::Logger.error("Error reading file #{file}\n")
27
+
28
+ raise e # unable to continue without required file
29
+ end
30
+
31
+ def setup_interrupt_handler(object_to_inspect)
32
+ Signal.trap("INT") do
33
+ puts "\n\nCaught CTRL-C! Printing before exiting:\n"
34
+ puts JSON.pretty_generate(object_to_inspect)
35
+ exit(1)
36
+ end
37
+ end
38
+
39
+ def setup_exit_handler(context)
40
+ # Hook that runs on any exit (including crashes and unhandled exceptions)
41
+ at_exit do
42
+ if $ERROR_INFO && !$ERROR_INFO.is_a?(SystemExit) # If exiting due to unhandled exception
43
+ puts "\n\nExiting due to error: #{$ERROR_INFO.class}: #{$ERROR_INFO.message}\n"
44
+ # Temporary disable the debugger to fix directory issues
45
+ # context.instance_eval { binding.irb }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "forwardable"
5
+
6
+ module Roast
7
+ module Workflow
8
+ class BaseStep
9
+ extend Forwardable
10
+
11
+ attr_accessor :model, :print_response, :loop, :json, :params
12
+ attr_reader :workflow, :name, :context_path
13
+
14
+ def_delegator :workflow, :append_to_final_output
15
+ def_delegator :workflow, :chat_completion
16
+ def_delegator :workflow, :transcript
17
+
18
+ def initialize(workflow, model: "anthropic:claude-3-7-sonnet", name: nil, context_path: nil)
19
+ @workflow = workflow
20
+ @model = model
21
+ @name = name || self.class.name.underscore.split("/").last
22
+ @context_path = context_path || determine_context_path
23
+ @print_response = false
24
+ @loop = true
25
+ @json = false
26
+ @params = {}
27
+ end
28
+
29
+ def call
30
+ prompt(read_sidecar_prompt)
31
+ chat_completion(print_response:, loop:, json:, params:)
32
+ end
33
+
34
+ protected
35
+
36
+ def chat_completion(print_response: false, loop: true, json: false, params: {})
37
+ workflow.chat_completion(openai: model, loop:, json:, params:).tap do |response|
38
+ append_to_final_output(response) if print_response
39
+ end.then do |response|
40
+ case response
41
+ in Array
42
+ response.map(&:presence).compact.join("\n")
43
+ else
44
+ response
45
+ end
46
+ end.tap do |response|
47
+ process_sidecar_output(response)
48
+ end
49
+ end
50
+
51
+ # Determine the directory where the actual class is defined, not BaseWorkflow
52
+ def determine_context_path
53
+ # Get the actual class's source file
54
+ klass = self.class
55
+
56
+ # Try to get the file path where the class is defined
57
+ path = if klass.name.include?("::")
58
+ # For namespaced classes like Roast::Workflow::Grading::Workflow
59
+ # Convert the class name to a relative path
60
+ class_path = klass.name.underscore + ".rb"
61
+ # Look through load path to find the actual file
62
+ $LOAD_PATH.map { |p| File.join(p, class_path) }.find { |f| File.exist?(f) }
63
+ else
64
+ # Fall back to the current file if we can't find it
65
+ __FILE__
66
+ end
67
+
68
+ # Return directory containing the class definition
69
+ File.dirname(path || __FILE__)
70
+ end
71
+
72
+ def prompt(text)
73
+ transcript << { user: text }
74
+ end
75
+
76
+ def read_sidecar_prompt
77
+ Roast::Helpers::PromptLoader.load_prompt(self, workflow.file)
78
+ end
79
+
80
+ def process_sidecar_output(response)
81
+ # look for a file named output.txt.erb in the context path
82
+ # if found, render it with the response
83
+ # if not found, just return the response
84
+ # TODO: this can be a lot more sophisticated
85
+ # incorporating different file types, etc.
86
+ output_path = File.join(context_path, "output.txt")
87
+ if File.exist?(output_path)
88
+ # TODO: use the workflow binding or the step?
89
+ append_to_final_output(ERB.new(File.read(output_path), trim_mode: "-").result(binding))
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "raix/chat_completion"
4
+ require "raix/function_dispatch"
5
+
6
+ module Roast
7
+ module Workflow
8
+ class BaseWorkflow
9
+ include Raix::ChatCompletion
10
+
11
+ attr_accessor :file,
12
+ :concise,
13
+ :output_file,
14
+ :subject_file,
15
+ :verbose,
16
+ :name,
17
+ :context_path,
18
+ :output
19
+
20
+ def initialize(file, subject_file = nil, name: nil, context_path: nil)
21
+ @file = file
22
+ @subject_file = subject_file
23
+ @name = name || self.class.name.underscore.split("/").last
24
+ @context_path = context_path || determine_context_path
25
+ @final_output = []
26
+ @output = {}
27
+ transcript << { system: read_sidecar_prompt }
28
+ unless subject_file.blank?
29
+ transcript << { user: read_subject_file }
30
+ end
31
+ Roast::Tools.setup_interrupt_handler(transcript)
32
+ Roast::Tools.setup_exit_handler(self)
33
+ end
34
+
35
+ def append_to_final_output(message)
36
+ @final_output << message
37
+ end
38
+
39
+ def final_output
40
+ @final_output.join("\n")
41
+ end
42
+
43
+ private
44
+
45
+ # Determine the directory where the actual class is defined, not BaseWorkflow
46
+ def determine_context_path
47
+ # Get the actual class's source file
48
+ klass = self.class
49
+
50
+ # Try to get the file path where the class is defined
51
+ path = if klass.name.include?("::")
52
+ # For namespaced classes like Roast::Workflow::Grading::Workflow
53
+ # Convert the class name to a relative path
54
+ class_path = klass.name.underscore + ".rb"
55
+ # Look through load path to find the actual file
56
+ $LOAD_PATH.map { |p| File.join(p, class_path) }.find { |f| File.exist?(f) }
57
+ else
58
+ # Fall back to the current file if we can't find it
59
+ __FILE__
60
+ end
61
+
62
+ # Return directory containing the class definition
63
+ File.dirname(path || __FILE__)
64
+ end
65
+
66
+ def read_sidecar_prompt
67
+ Roast::Helpers::PromptLoader.load_prompt(self, file)
68
+ end
69
+
70
+ def read_subject_file
71
+ [
72
+ "# SUT (Subject Under Test)",
73
+ "# #{subject_file}",
74
+ File.read(subject_file),
75
+ ].join("\n")
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "yaml"
5
+
6
+ module Roast
7
+ module Workflow
8
+ # Encapsulates workflow configuration data and provides structured access
9
+ # to the configuration settings
10
+ class Configuration
11
+ attr_reader :config_hash, :workflow_path, :name, :steps, :tools, :function_configs
12
+ attr_accessor :target
13
+
14
+ def initialize(workflow_path, options = {})
15
+ @workflow_path = workflow_path
16
+ @config_hash = YAML.load_file(workflow_path)
17
+
18
+ # Extract key configuration values
19
+ @name = @config_hash["name"] || File.basename(workflow_path, ".yml")
20
+ @steps = @config_hash["steps"] || []
21
+
22
+ # Process tools configuration
23
+ parse_tools
24
+
25
+ # Process function-specific configurations
26
+ parse_functions
27
+
28
+ # Read the target parameter
29
+ @target = options[:target] || @config_hash["target"]
30
+
31
+ # Process the target command if it's a shell command
32
+ @target = process_target(@target) if has_target?
33
+ end
34
+
35
+ def context_path
36
+ @context_path ||= File.dirname(workflow_path)
37
+ end
38
+
39
+ def basename
40
+ @basename ||= File.basename(workflow_path, ".yml")
41
+ end
42
+
43
+ def has_target?
44
+ !target.nil? && !target.empty?
45
+ end
46
+
47
+ def get_step_config(step_name)
48
+ @config_hash[step_name] || {}
49
+ end
50
+
51
+ def find_step_index(steps, target_step)
52
+ steps.each_with_index do |step, index|
53
+ step_name = extract_step_name(step)
54
+ if step_name.is_a?(Array)
55
+ # For arrays (parallel steps), check if target is in the array
56
+ return index if step_name.flatten.include?(target_step)
57
+ elsif step_name == target_step
58
+ return index
59
+ end
60
+ end
61
+ nil
62
+ end
63
+
64
+ # Returns an array of all tool class names
65
+ def parse_tools
66
+ # Only support array format: ["Roast::Tools::Grep", "Roast::Tools::ReadFile"]
67
+ @tools = @config_hash["tools"] || []
68
+ end
69
+
70
+ # Parse function-specific configurations
71
+ def parse_functions
72
+ @function_configs = @config_hash["functions"] || {}
73
+ end
74
+
75
+ # Get configuration for a specific function
76
+ # @param function_name [String, Symbol] The name of the function (e.g., 'grep', 'search_file')
77
+ # @return [Hash] The configuration for the function or empty hash if not found
78
+ def function_config(function_name)
79
+ @function_configs[function_name.to_s] || {}
80
+ end
81
+
82
+ private
83
+
84
+ def process_target(command)
85
+ # If it's a bash command with the new $(command) syntax
86
+ if command =~ /^\$\((.*)\)$/
87
+ return Open3.capture2e({}, ::Regexp.last_match(1)).first.strip
88
+ end
89
+
90
+ # Legacy % prefix for backward compatibility
91
+ if command.start_with?("% ")
92
+ return Open3.capture2e({}, *command.split(" ")[1..-1]).first.strip
93
+ end
94
+
95
+ # If it's a glob pattern, return the full paths of the files it matches
96
+ if command.include?("*")
97
+ return Dir.glob(command).map { |file| File.expand_path(file) }.join("\n")
98
+ end
99
+
100
+ # assumed to be a direct file path(s)
101
+ File.expand_path(command)
102
+ end
103
+
104
+ def extract_step_name(step)
105
+ case step
106
+ when String
107
+ step
108
+ when Hash
109
+ step.keys.first
110
+ when Array
111
+ # For arrays, we'll need special handling as they contain multiple steps
112
+ step.map { |s| extract_step_name(s) }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workflow_executor"
4
+ require_relative "configuration"
5
+ require_relative "../helpers/function_caching_interceptor"
6
+
7
+ module Roast
8
+ module Workflow
9
+ class ConfigurationParser
10
+ extend Forwardable
11
+
12
+ attr_reader :configuration, :options, :files, :current_workflow
13
+
14
+ def_delegator :current_workflow, :output
15
+
16
+ def initialize(workflow_path, files = [], options = {})
17
+ @configuration = Configuration.new(workflow_path, options)
18
+ @options = options
19
+ @files = files
20
+ include_tools
21
+ end
22
+
23
+ def begin!
24
+ $stderr.puts "Starting workflow..."
25
+ $stderr.puts "Workflow: #{configuration.workflow_path}"
26
+ $stderr.puts "Options: #{options}"
27
+
28
+ name = configuration.basename
29
+ context_path = configuration.context_path
30
+
31
+ if files.any?
32
+ $stderr.puts "WARNING: Ignoring target parameter because files were provided: #{configuration.target}" if configuration.has_target?
33
+ files.each do |file|
34
+ $stderr.puts "Running workflow for file: #{file}"
35
+ setup_workflow(file.strip, name:, context_path:)
36
+ parse(configuration.steps)
37
+ end
38
+ elsif configuration.has_target?
39
+ configuration.target.lines.each do |file|
40
+ $stderr.puts "Running workflow for file: #{file.strip}"
41
+ setup_workflow(file.strip, name:, context_path:)
42
+ parse(configuration.steps)
43
+ end
44
+ else
45
+ $stdout.puts "🚫 ERROR: No files or target provided! 🚫"
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def setup_workflow(file, name:, context_path:)
52
+ @current_workflow = BaseWorkflow.new(file, name:, context_path:).tap do |workflow|
53
+ workflow.output_file = options[:output] if options[:output].present?
54
+ workflow.subject_file = options[:subject] if options[:subject].present?
55
+ workflow.verbose = options[:verbose] if options[:verbose].present?
56
+ end
57
+ end
58
+
59
+ def include_tools
60
+ return unless configuration.tools.present?
61
+
62
+ BaseWorkflow.include(Raix::FunctionDispatch)
63
+ BaseWorkflow.include(Roast::Helpers::FunctionCachingInterceptor) # Add caching support
64
+ BaseWorkflow.include(*configuration.tools.map(&:constantize))
65
+ end
66
+
67
+ def parse(steps)
68
+ return run(steps) if steps.is_a?(String)
69
+
70
+ # Use the WorkflowExecutor to execute the steps
71
+ executor = WorkflowExecutor.new(current_workflow, configuration.config_hash, configuration.context_path)
72
+ executor.execute_steps(steps)
73
+
74
+ $stderr.puts "🔥🔥🔥 ROAST COMPLETE! 🔥🔥🔥"
75
+
76
+ # Save results to file if specified
77
+ if current_workflow.output_file
78
+ File.write(current_workflow.output_file, current_workflow.final_output)
79
+ $stdout.puts "Results saved to #{current_workflow.output_file}"
80
+ else
81
+ $stdout.puts current_workflow.final_output
82
+ end
83
+ end
84
+
85
+ # Delegates to WorkflowExecutor
86
+ def run(name)
87
+ executor = WorkflowExecutor.new(current_workflow, configuration.config_hash, configuration.context_path)
88
+ executor.execute_step(name)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json-schema"
4
+ require "json"
5
+ require "yaml"
6
+
7
+ module Roast
8
+ module Workflow
9
+ class Validator
10
+ attr_reader :errors
11
+
12
+ def initialize(yaml_content)
13
+ @yaml_content = yaml_content&.strip || ""
14
+ @errors = []
15
+
16
+ @parsed_yaml = @yaml_content.empty? ? {} : YAML.safe_load(@yaml_content)
17
+ end
18
+
19
+ def valid?
20
+ return false if @parsed_yaml.empty?
21
+
22
+ schema_path = File.join(Roast::ROOT, "schema/workflow.json")
23
+ schema = JSON.parse(File.read(schema_path))
24
+
25
+ begin
26
+ @errors = JSON::Validator.fully_validate(
27
+ schema,
28
+ @parsed_yaml,
29
+ validate_schema: false,
30
+ )
31
+
32
+ @errors.empty?
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end