Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduced Nested Template Support in Prompts. #7

Merged
merged 5 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,28 @@ Code Refactoring:

**Documentation:** Updated class-level documentation and method comments for better clarity and understanding of the class’s functionality and usage.

This version enhances the flexibility and robustness of the Completions class, enabling more complex interactions and better error handling for different types of API responses.
This version enhances the flexibility and robustness of the Completions class, enabling more complex interactions and better error handling for different types of API responses.

# Changelog for Version 1.1.1

**Release Date:** [11th Oct 2024]

**New Features:**

* **Root Attribute in Configuration:**

* Introduced a `root` attribute to the Spectre configuration. This allows users to specify a custom root directory for loading prompts or other templates.
* Example usage in initializer:
```ruby
Spectre.setup do |config|
config.api_key = 'your_openai_api_key'
config.root = Rails.root # or any custom path
end
```
* If `root` is not set, Spectre will default to the current working directory (Dir.pwd).
* This is especially useful when integrating Spectre into other gems or non-Rails projects where the root directory might differ.


* **Prompt Path Detection:**
* Prompt paths now use the configured `root` to locate the template files. This ensures that Spectre works correctly in various environments where template paths may vary.

2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
spectre_ai (1.1.0)
spectre_ai (1.1.1)

GEM
remote: https://rubygems.org/
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ This will create a file at `config/initializers/spectre.rb`, where you can set y
Spectre.setup do |config|
config.api_key = 'your_openai_api_key'
config.llm_provider = :openai
config.root = Rails.root # Optional: Set the root path for the gem
end
```

Expand Down
2 changes: 2 additions & 0 deletions lib/generators/spectre/templates/spectre_initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
config.llm_provider = :openai
# Set the API key for your chosen LLM
config.api_key = ENV.fetch('CHATGPT_API_TOKEN')
# Set the root directory for your project (optional)
# config.root = File.expand_path('..', __dir__)
end
2 changes: 1 addition & 1 deletion lib/spectre.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def spectre(*modules)
end

class << self
attr_accessor :api_key, :llm_provider
attr_accessor :api_key, :llm_provider, :root

def setup
yield self
Expand Down
203 changes: 109 additions & 94 deletions lib/spectre/prompt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,107 +5,122 @@

module Spectre
class Prompt
PROMPTS_PATH = File.join(Dir.pwd, 'app', 'spectre', 'prompts')

# Render a prompt by reading and rendering the YAML template
#
# @param template [String] The path to the template file, formatted as 'type/prompt' (e.g., 'rag/system')
# @param locals [Hash] Variables to be passed to the template for rendering
#
# @return [String] Rendered prompt
def self.render(template:, locals: {})
type, prompt = split_template(template)
file_path = prompt_file_path(type, prompt)

raise "Prompt file not found: #{file_path}" unless File.exist?(file_path)

# Preprocess the locals before rendering the YAML file
preprocessed_locals = preprocess_locals(locals)

template_content = File.read(file_path)
erb_template = ERB.new(template_content)

context = Context.new(preprocessed_locals)
rendered_prompt = erb_template.result(context.get_binding)

# YAML.safe_load returns a hash, so fetch the correct part based on the prompt
parsed_yaml = YAML.safe_load(rendered_prompt)[prompt]

# Convert special characters back after YAML processing
convert_special_chars_back(parsed_yaml)
rescue Errno::ENOENT
raise "Template file not found at path: #{file_path}"
rescue Psych::SyntaxError => e
raise "YAML Syntax Error in file #{file_path}: #{e.message}"
rescue StandardError => e
raise "Error rendering prompt for template '#{template}': #{e.message}"
end
class << self
attr_reader :prompts_path

private
def prompts_path
@prompts_path ||= detect_prompts_path
end

# Split the template parameter into type and prompt
#
# @param template [String] Template path in the format 'type/prompt' (e.g., 'rag/system')
# @return [Array<String, String>] An array containing the type and prompt
def self.split_template(template)
template.split('/')
end
# Render a prompt by reading and rendering the YAML template
#
# @param template [String] The path to the template file, formatted as 'type/prompt' (e.g., 'rag/system')
# @param locals [Hash] Variables to be passed to the template for rendering
#
# @return [String] Rendered prompt
def render(template:, locals: {})
type, prompt = split_template(template)
file_path = prompt_file_path(type, prompt)

raise "Prompt file not found: #{file_path}" unless File.exist?(file_path)

# Preprocess the locals before rendering the YAML file
preprocessed_locals = preprocess_locals(locals)

template_content = File.read(file_path)
erb_template = ERB.new(template_content)

context = Context.new(preprocessed_locals)
rendered_prompt = erb_template.result(context.get_binding)

# YAML.safe_load returns a hash, so fetch the correct part based on the prompt
parsed_yaml = YAML.safe_load(rendered_prompt)[prompt]

# Convert special characters back after YAML processing
convert_special_chars_back(parsed_yaml)
rescue Errno::ENOENT
raise "Template file not found at path: #{file_path}"
rescue Psych::SyntaxError => e
raise "YAML Syntax Error in file #{file_path}: #{e.message}"
rescue StandardError => e
raise "Error rendering prompt for template '#{template}': #{e.message}"
end

# Build the path to the desired prompt file
#
# @param type [String] Name of the prompt folder
# @param prompt [String] Type of prompt (e.g., 'system', 'user')
#
# @return [String] Full path to the template file
def self.prompt_file_path(type, prompt)
"#{PROMPTS_PATH}/#{type}/#{prompt}.yml.erb"
end
private

# Preprocess locals recursively to escape special characters in strings
#
# @param value [Object] The value to process (string, array, hash, etc.)
# @return [Object] Processed value with special characters escaped
def self.preprocess_locals(value)
case value
when String
escape_special_chars(value)
when Hash
value.transform_values { |v| preprocess_locals(v) } # Recurse into hash values
when Array
value.map { |item| preprocess_locals(item) } # Recurse into array items
else
value
# Detects the appropriate path for prompt templates
def detect_prompts_path
if Spectre.root
File.join(Spectre.root, 'app', 'spectre', 'prompts')
matthewblack marked this conversation as resolved.
Show resolved Hide resolved
else
File.join(Dir.pwd, 'app', 'spectre', 'prompts')
end
end
end

# Escape special characters in strings to avoid YAML parsing issues
#
# @param value [String] The string to process
# @return [String] The processed string with special characters escaped
def self.escape_special_chars(value)
value.gsub('&', '&amp;')
.gsub('<', '&lt;')
.gsub('>', '&gt;')
.gsub('"', '&quot;')
.gsub("'", '&#39;')
.gsub("\n", '\\n')
.gsub("\r", '\\r')
.gsub("\t", '\\t')
end
# Split the template parameter into type and prompt
#
# @param template [String] Template path in the format 'type/prompt' (e.g., 'rag/system')
# @return [Array<String, String>] An array containing the type and prompt
def split_template(template)
template.split('/')
end

# Convert special characters back to their original form after YAML processing
#
# @param value [String] The string to process
# @return [String] The processed string with original special characters restored
def self.convert_special_chars_back(value)
value.gsub('&amp;', '&')
.gsub('&lt;', '<')
.gsub('&gt;', '>')
.gsub('&quot;', '"')
.gsub('&#39;', "'")
.gsub('\\n', "\n")
.gsub('\\r', "\r")
.gsub('\\t', "\t")
# Build the path to the desired prompt file
#
# @param type [String] Name of the prompt folder
# @param prompt [String] Type of prompt (e.g., 'system', 'user')
#
# @return [String] Full path to the template file
def prompt_file_path(type, prompt)
File.join(prompts_path, type, "#{prompt}.yml.erb")
end

# Preprocess locals recursively to escape special characters in strings
#
# @param value [Object] The value to process (string, array, hash, etc.)
# @return [Object] Processed value with special characters escaped
def preprocess_locals(value)
case value
when String
escape_special_chars(value)
when Hash
value.transform_values { |v| preprocess_locals(v) } # Recurse into hash values
when Array
value.map { |item| preprocess_locals(item) } # Recurse into array items
else
value
end
end

# Escape special characters in strings to avoid YAML parsing issues
#
# @param value [String] The string to process
# @return [String] The processed string with special characters escaped
def escape_special_chars(value)
value.gsub('&', '&amp;')
.gsub('<', '&lt;')
.gsub('>', '&gt;')
.gsub('"', '&quot;')
.gsub("'", '&#39;')
.gsub("\n", '\\n')
.gsub("\r", '\\r')
.gsub("\t", '\\t')
end

# Convert special characters back to their original form after YAML processing
#
# @param value [String] The string to process
# @return [String] The processed string with original special characters restored
def convert_special_chars_back(value)
value.gsub('&amp;', '&')
.gsub('&lt;', '<')
.gsub('&gt;', '>')
.gsub('&quot;', '"')
.gsub('&#39;', "'")
.gsub('\\n', "\n")
.gsub('\\r', "\r")
.gsub('\\t', "\t")
end
end

# Helper class to handle the binding for ERB template rendering
Expand Down
2 changes: 1 addition & 1 deletion lib/spectre/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Spectre # :nodoc:all
VERSION = "1.1.0"
VERSION = "1.1.1"
end
32 changes: 30 additions & 2 deletions spec/spectre/prompt_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
File.write(File.join(prompts_folder, 'system.yml.erb'), system_prompt_content)
File.write(File.join(prompts_folder, 'user.yml.erb'), user_prompt_content)

# Temporarily set the PROMPTS_PATH to the tmp directory
stub_const('Spectre::Prompt::PROMPTS_PATH', @tmpdir)
# Temporarily set the prompts_path to the tmp directory
allow(Spectre::Prompt).to receive(:prompts_path).and_return(@tmpdir)
end

after do
Expand Down Expand Up @@ -78,5 +78,33 @@
expect(result).to eq(expected_result)
end
end

context 'when the prompt file is not found' do
it 'raises an error indicating the file is missing' do
expect {
described_class.render(template: 'nonexistent_template')
}.to raise_error(RuntimeError, /Prompt file not found/)
end
end

context 'when there is a YAML syntax error in the prompt file' do
let(:invalid_yaml_content) do
<<~ERB
system: |
You are a helpful assistant.
This line should cause a YAML error because it is missing indentation or a key
ERB
end

before do
File.write(File.join(@tmpdir, 'rag/system.yml.erb'), invalid_yaml_content)
end

it 'raises a YAML syntax error' do
expect {
described_class.render(template: 'rag/system')
}.to raise_error(RuntimeError, /YAML Syntax Error/)
end
end
end
end
Loading