diff --git a/lib/ruby_llm.rb b/lib/ruby_llm.rb index 2ff192d65..6791eb74c 100644 --- a/lib/ruby_llm.rb +++ b/lib/ruby_llm.rb @@ -68,6 +68,10 @@ def transcribe(...) Transcription.transcribe(...) end + def render_prompt(name, **locals) + Prompt.render(name, **locals) + end + def models Models.instance end diff --git a/lib/ruby_llm/agent.rb b/lib/ruby_llm/agent.rb index 7e1e8509c..b5ed8049d 100644 --- a/lib/ruby_llm/agent.rb +++ b/lib/ruby_llm/agent.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require 'erb' require 'forwardable' -require 'pathname' require 'ruby_llm/schema' module RubyLLM @@ -140,14 +138,8 @@ def sync_instructions!(chat_or_id, **kwargs) end def render_prompt(name, chat:, inputs:, locals:) - path = prompt_path_for(name) - unless File.exist?(path) - raise RubyLLM::PromptNotFoundError, - "Prompt file not found for #{self}: #{path}. Create the file or use inline instructions." - end - resolved_locals = resolve_prompt_locals(locals, runtime: runtime_context(chat:, inputs:), chat:, inputs:) - ERB.new(File.read(path)).result_with_hash(resolved_locals) + RubyLLM.render_prompt("#{prompt_agent_path}/#{name}", **resolved_locals) end private @@ -315,12 +307,6 @@ def runtime_context(chat:, inputs:) end end - def prompt_path_for(name) - filename = name.to_s - filename += '.txt.erb' unless filename.end_with?('.txt.erb') - prompt_root.join(prompt_agent_path, filename) - end - def prompt_agent_path class_name = name || 'agent' class_name.gsub('::', '/') @@ -330,14 +316,6 @@ def prompt_agent_path .downcase end - def prompt_root - if defined?(Rails) && Rails.respond_to?(:root) && Rails.root - Rails.root.join('app/prompts') - else - Pathname.new(Dir.pwd).join('app/prompts') - end - end - def resolved_chat_model return @resolved_chat_model if defined?(@resolved_chat_model) diff --git a/lib/ruby_llm/prompt.rb b/lib/ruby_llm/prompt.rb new file mode 100644 index 000000000..7d23e4725 --- /dev/null +++ b/lib/ruby_llm/prompt.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'erb' +require 'pathname' + +module RubyLLM + # Renders ERB prompt templates from the prompts directory. + class Prompt + attr_reader :name, :path + + def initialize(name) + @name = name.to_s + @path = self.class.root.join( + @name.end_with?('.txt.erb') ? @name : "#{@name}.txt.erb" + ) + end + + def render(**locals) + raise PromptNotFoundError, "Prompt file not found: #{@path}" unless File.exist?(@path) + + ERB.new(File.read(@path)).result_with_hash(locals) + end + + def self.render(name, **locals) + new(name).render(**locals) + end + + def self.root + if defined?(Rails) && Rails.respond_to?(:root) && Rails.root + Rails.root.join('app/prompts') + else + Pathname.new(Dir.pwd).join('app/prompts') + end + end + end +end diff --git a/spec/ruby_llm/prompt_spec.rb b/spec/ruby_llm/prompt_spec.rb new file mode 100644 index 000000000..9bdefa693 --- /dev/null +++ b/spec/ruby_llm/prompt_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe RubyLLM::Prompt do + let(:tmpdir) { Dir.mktmpdir } + let(:prompt_dir) { Pathname.new(tmpdir).join('app/prompts') } + + before do + prompt_dir.mkpath + allow(described_class).to receive(:root).and_return(prompt_dir) + end + + after do + FileUtils.rm_rf(tmpdir) + end + + def create_prompt(name, content) + path = prompt_dir.join("#{name}.txt.erb") + path.dirname.mkpath + path.write(content) + end + + describe '.render' do + it 'renders a prompt with locals' do + create_prompt('friend', 'Hello, <%= name %>!') + expect(described_class.render('friend', name: 'Andrey')).to eq('Hello, Andrey!') + end + + it 'renders a nested prompt path' do + create_prompt('work_assistant/instructions', 'You assist <%= user %>.') + expect(described_class.render('work_assistant/instructions', user: 'Bob')).to eq('You assist Bob.') + end + + it 'renders without locals' do + create_prompt('simple', 'Just a static prompt.') + expect(described_class.render('simple')).to eq('Just a static prompt.') + end + + it 'raises PromptNotFoundError for missing prompts' do + expect { described_class.render('nonexistent') }.to raise_error(RubyLLM::PromptNotFoundError) + end + end + + describe '#render' do + it 'renders the prompt with locals' do + create_prompt('greeting', 'Hi <%= name %>, welcome!') + prompt = described_class.new('greeting') + expect(prompt.render(name: 'Andrey')).to eq('Hi Andrey, welcome!') + end + + it 'exposes name and path' do + prompt = described_class.new('greeting') + expect(prompt.name).to eq('greeting') + expect(prompt.path).to eq(prompt_dir.join('greeting.txt.erb')) + end + end + + describe 'RubyLLM.render_prompt' do + it 'renders a prompt with locals through the top-level entrypoint' do + create_prompt('friend', 'Hello, <%= name %>!') + expect(RubyLLM.render_prompt('friend', name: 'Andrey')).to eq('Hello, Andrey!') + end + + it 'renders a nested prompt path' do + create_prompt('work_assistant/instructions', 'You assist <%= user %>.') + expect(RubyLLM.render_prompt('work_assistant/instructions', user: 'Bob')).to eq('You assist Bob.') + end + + it 'raises PromptNotFoundError for missing prompts' do + expect { RubyLLM.render_prompt('nonexistent') }.to raise_error(RubyLLM::PromptNotFoundError) + end + end +end