├── .rspec ├── .gitignore ├── .standard.yml ├── lib ├── ruby_llm │ ├── template │ │ ├── version.rb │ │ ├── railtie.rb │ │ ├── configuration.rb │ │ ├── chat_extension.rb │ │ └── loader.rb │ └── template.rb └── generators │ └── ruby_llm │ └── template │ └── install_generator.rb ├── bin ├── setup └── console ├── sig └── ruby_llm │ └── template.rbs ├── Rakefile ├── Gemfile ├── .github ├── release.yml └── workflows │ ├── ci.yml │ └── release.yml ├── LICENSE.txt ├── spec ├── spec_helper.rb └── ruby_llm │ ├── template │ ├── configuration_spec.rb │ ├── chat_extension_spec.rb │ ├── schema_only_spec.rb │ ├── loader_spec.rb │ └── schema_integration_spec.rb │ └── template_spec.rb ├── ruby_llm-template.gemspec ├── CHANGELOG.md ├── Gemfile.lock ├── examples └── basic_usage.rb └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --format documentation 3 | --color 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/standardrb/standard 3 | ruby_version: 3.1 4 | -------------------------------------------------------------------------------- /lib/ruby_llm/template/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module Template 5 | VERSION = "0.1.6" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /sig/ruby_llm/template.rbs: -------------------------------------------------------------------------------- 1 | module RubyLLM 2 | module Template 3 | VERSION: String 4 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "standard/rake" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | task default: :spec 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in ruby_llm-template.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "standard", "~> 1.3" 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "ruby_llm/template" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /lib/ruby_llm/template/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module Template 5 | class Railtie < Rails::Railtie 6 | # Register generators 7 | generators do 8 | require_relative "../../generators/ruby_llm/template/install_generator" 9 | end 10 | 11 | initializer "ruby_llm_template.configure" do |app| 12 | # Set default template directory for Rails applications 13 | RubyLLM::Template.configure do |config| 14 | config.template_directory ||= app.root.join("app", "prompts") 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ruby_llm/template/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module Template 5 | class Configuration 6 | attr_writer :template_directory 7 | 8 | def initialize 9 | @template_directory = nil 10 | end 11 | 12 | def template_directory 13 | @template_directory || default_template_directory 14 | end 15 | 16 | private 17 | 18 | def default_template_directory 19 | if defined?(Rails) && Rails.respond_to?(:root) && Rails.root 20 | Rails.root.join("app", "prompts") 21 | else 22 | File.join(Dir.pwd, "prompts") 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: '🚀 Features' 4 | labels: 5 | - 'feature' 6 | - 'enhancement' 7 | - title: '🐛 Bug Fixes' 8 | labels: 9 | - 'bug' 10 | - 'fix' 11 | - title: '📚 Documentation' 12 | labels: 13 | - 'documentation' 14 | - 'docs' 15 | - title: '🧹 Maintenance' 16 | labels: 17 | - 'chore' 18 | - 'maintenance' 19 | - 'refactor' 20 | - title: '⚡ Performance' 21 | labels: 22 | - 'performance' 23 | - title: '🔒 Security' 24 | labels: 25 | - 'security' 26 | exclude: 27 | labels: 28 | - 'skip-changelog' 29 | - 'duplicate' 30 | - 'invalid' 31 | - 'wontfix' 32 | authors: 33 | - 'dependabot' 34 | - 'dependabot[bot]' -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Daniel Friis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ruby_llm/template" 4 | require "rspec" 5 | require "tmpdir" 6 | require "fileutils" 7 | 8 | RSpec.configure do |config| 9 | config.expect_with :rspec do |expectations| 10 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 11 | end 12 | 13 | config.mock_with :rspec do |mocks| 14 | mocks.verify_partial_doubles = true 15 | end 16 | 17 | config.shared_context_metadata_behavior = :apply_to_host_groups 18 | 19 | # Create a temporary directory for each test 20 | config.around(:each) do |example| 21 | Dir.mktmpdir do |tmpdir| 22 | @tmpdir = tmpdir 23 | example.run 24 | end 25 | ensure 26 | RubyLLM::Template.reset_configuration! 27 | end 28 | 29 | config.before(:each) do 30 | # Reset configuration before each test 31 | RubyLLM::Template.reset_configuration! 32 | end 33 | end 34 | 35 | def create_test_template(name, templates = {}) 36 | template_dir = File.join(@tmpdir, name.to_s) 37 | FileUtils.mkdir_p(template_dir) 38 | 39 | templates.each do |role, content| 40 | File.write(File.join(template_dir, "#{role}.txt.erb"), content) 41 | end 42 | 43 | template_dir 44 | end 45 | -------------------------------------------------------------------------------- /spec/ruby_llm/template/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe RubyLLM::Template::Configuration do 6 | subject(:config) { described_class.new } 7 | 8 | describe "#template_directory" do 9 | context "when not set" do 10 | it "returns default directory" do 11 | expect(config.template_directory).to eq(File.join(Dir.pwd, "prompts")) 12 | end 13 | end 14 | 15 | context "when set" do 16 | it "returns the set directory" do 17 | config.template_directory = "/custom/path" 18 | expect(config.template_directory).to eq("/custom/path") 19 | end 20 | end 21 | 22 | context "when Rails is defined" do 23 | before do 24 | stub_const("Rails", double("Rails", root: double("Root", join: "/rails/app/prompts"))) 25 | end 26 | 27 | it "returns Rails default directory" do 28 | expect(config.template_directory).to eq("/rails/app/prompts") 29 | end 30 | end 31 | end 32 | 33 | describe "#template_directory=" do 34 | it "sets the template directory" do 35 | config.template_directory = "/new/path" 36 | expect(config.instance_variable_get(:@template_directory)).to eq("/new/path") 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/ruby_llm/template/chat_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RubyLLM 4 | module Template 5 | module ChatExtension 6 | def with_template(template_name, context = {}) 7 | loader = RubyLLM::Template::Loader.new(template_name) 8 | 9 | unless loader.template_exists? 10 | raise RubyLLM::Template::Error, "Template '#{template_name}' not found in #{RubyLLM::Template.configuration.template_directory}" 11 | end 12 | 13 | # Apply templates in a specific order to maintain conversation flow 14 | template_order = ["system", "user", "assistant"] 15 | 16 | template_order.each do |role| 17 | next unless loader.available_roles.include?(role) 18 | 19 | content = loader.render_template(role, context) 20 | next unless content && !content.strip.empty? 21 | 22 | add_message(role: role, content: content.strip) 23 | end 24 | 25 | # Handle schema separately if it exists 26 | if loader.available_roles.include?("schema") 27 | schema_result = loader.render_template("schema", context) 28 | 29 | if schema_result 30 | if schema_result.respond_to?(:to_json_schema) 31 | # It's a RubyLLM::Schema instance 32 | with_schema(schema_result.to_json_schema) 33 | else 34 | # It's a schema class 35 | with_schema(schema_result) 36 | end 37 | end 38 | end 39 | 40 | self 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/ruby_llm/template_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe RubyLLM::Template do 6 | it "has a version number" do 7 | expect(RubyLLM::Template::VERSION).not_to be nil 8 | end 9 | 10 | describe ".configuration" do 11 | it "returns a configuration instance" do 12 | expect(described_class.configuration).to be_a(RubyLLM::Template::Configuration) 13 | end 14 | 15 | it "returns the same instance on multiple calls" do 16 | config1 = described_class.configuration 17 | config2 = described_class.configuration 18 | expect(config1).to be(config2) 19 | end 20 | end 21 | 22 | describe ".configure" do 23 | it "yields the configuration" do 24 | expect { |b| described_class.configure(&b) }.to yield_with_args(described_class.configuration) 25 | end 26 | 27 | it "allows setting template directory" do 28 | described_class.configure do |config| 29 | config.template_directory = "/custom/path" 30 | end 31 | 32 | expect(described_class.configuration.template_directory).to eq("/custom/path") 33 | end 34 | end 35 | 36 | describe ".reset_configuration!" do 37 | it "resets the configuration" do 38 | described_class.configure { |config| config.template_directory = "/test" } 39 | original_config = described_class.configuration 40 | 41 | described_class.reset_configuration! 42 | new_config = described_class.configuration 43 | 44 | expect(new_config).not_to be(original_config) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_call: # Add this line to make it reusable 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | ruby: ['3.2', '3.3'] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | 28 | - name: Run tests 29 | run: bundle exec rspec 30 | 31 | - name: Run Standard 32 | run: bundle exec standardrb 33 | 34 | security: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout code 38 | uses: actions/checkout@v4 39 | 40 | - name: Set up Ruby 41 | uses: ruby/setup-ruby@v1 42 | with: 43 | ruby-version: '3.2' 44 | bundler-cache: true 45 | 46 | - name: Run bundle audit 47 | run: | 48 | gem install bundler-audit 49 | bundle audit --update 50 | 51 | gem-build: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Checkout code 55 | uses: actions/checkout@v4 56 | 57 | - name: Set up Ruby 58 | uses: ruby/setup-ruby@v1 59 | with: 60 | ruby-version: '3.2' 61 | bundler-cache: true 62 | 63 | - name: Build gem 64 | run: gem build *.gemspec 65 | 66 | - name: Upload gem artifact 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: gem-file 70 | path: '*.gem' 71 | -------------------------------------------------------------------------------- /lib/ruby_llm/template.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "template/version" 4 | require_relative "template/configuration" 5 | require_relative "template/loader" 6 | require_relative "template/chat_extension" 7 | 8 | # Load Rails integration if Rails is available 9 | begin 10 | require "rails" 11 | require_relative "template/railtie" 12 | rescue LoadError 13 | # Rails not available 14 | end 15 | 16 | module RubyLLM 17 | module Template 18 | class Error < StandardError; end 19 | 20 | def self.configuration 21 | @configuration ||= Configuration.new 22 | end 23 | 24 | def self.configure 25 | yield(configuration) 26 | end 27 | 28 | def self.reset_configuration! 29 | @configuration = nil 30 | end 31 | end 32 | end 33 | 34 | # Extend RubyLLM's Chat class if it's available 35 | begin 36 | require "ruby_llm" 37 | 38 | if defined?(RubyLLM) && RubyLLM.respond_to?(:chat) 39 | # We need to extend the actual chat class returned by RubyLLM.chat 40 | # This is a monkey patch approach, but necessary for the API we want 41 | 42 | module RubyLLMChatTemplateExtension 43 | def self.extended(base) 44 | base.extend(RubyLLM::Template::ChatExtension) 45 | end 46 | end 47 | 48 | # Hook into RubyLLM.chat to extend the returned object 49 | module RubyLLMTemplateHook 50 | def chat(*args, **kwargs) 51 | chat_instance = super 52 | chat_instance.extend(RubyLLM::Template::ChatExtension) 53 | chat_instance 54 | end 55 | end 56 | 57 | if defined?(RubyLLM) 58 | RubyLLM.singleton_class.prepend(RubyLLMTemplateHook) 59 | end 60 | end 61 | rescue LoadError 62 | # RubyLLM not available, extension will be loaded when it becomes available 63 | end 64 | -------------------------------------------------------------------------------- /ruby_llm-template.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/ruby_llm/template/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "ruby_llm-template" 7 | spec.version = RubyLLM::Template::VERSION 8 | spec.authors = ["Daniel Friis"] 9 | spec.email = ["d@friis.me"] 10 | 11 | spec.summary = "Template management system for RubyLLM - organize and reuse ERB templates for AI chat interactions" 12 | spec.description = "RubyLLM::Template provides a flexible template system for RubyLLM, allowing you to organize chat prompts, system messages, and schemas in ERB template files for easy reuse and maintenance." 13 | spec.homepage = "https://github.com/danielfriis/ruby_llm-template" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.1.0" 16 | 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 18 | spec.metadata["homepage_uri"] = spec.homepage 19 | spec.metadata["source_code_uri"] = "https://github.com/danielfriis/ruby_llm-template" 20 | spec.metadata["changelog_uri"] = "https://github.com/danielfriis/ruby_llm-template/blob/main/CHANGELOG.md" 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | gemspec = File.basename(__FILE__) 25 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 26 | ls.readlines("\x0", chomp: true).reject do |f| 27 | (f == gemspec) || 28 | f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile]) 29 | end 30 | end 31 | spec.bindir = "exe" 32 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 33 | spec.require_paths = ["lib"] 34 | 35 | spec.add_dependency "ruby_llm", ">= 1.0" 36 | spec.add_dependency "ruby_llm-schema", ">= 0.2.0" 37 | 38 | spec.add_development_dependency "rspec", "~> 3.12" 39 | 40 | # For more information and examples about making a new gem, check out our 41 | # guide at: https://bundler.io/guides/creating_gem.html 42 | end 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.0] - 2025-01-28 9 | 10 | ### Added 11 | - Initial release of RubyLLM::Template 12 | - Template management system for RubyLLM with ERB support 13 | - Configuration system for template directories 14 | - Support for system, user, assistant, and schema message templates 15 | - **RubyLLM::Schema Integration**: Support for `schema.rb` files using the RubyLLM::Schema DSL 16 | - Rails integration with automatic configuration and generator 17 | - Comprehensive test suite with 37 test cases 18 | - Error handling with descriptive messages 19 | - Documentation and examples 20 | 21 | ### Features 22 | - **Template Organization**: Structure prompts in folders with separate ERB files for each message role 23 | - **ERB Templating**: Full ERB support with context variables and Ruby logic 24 | - **Schema Definition**: Use `schema.rb` files with RubyLLM::Schema DSL for type-safe, dynamic schemas 25 | - **Rails Integration**: Seamless Rails integration with generators and automatic configuration 26 | - **Configurable**: Set custom template directories per environment 27 | - **Schema Support**: Automatic schema loading and application with fallback to JSON 28 | - **Error Handling**: Clear error messages for common issues 29 | - **Smart Dependencies**: Optional RubyLLM::Schema dependency with graceful fallbacks 30 | 31 | ### Schema Features 32 | - **Ruby DSL**: Use RubyLLM::Schema for clean, type-safe schema definitions 33 | - **Context Variables**: Access template context variables within schema.rb files 34 | - **Dynamic Schemas**: Generate schemas based on runtime conditions 35 | - **Schema-Only Approach**: Exclusively supports schema.rb files with clear error messages 36 | - **No JSON Fallback**: Eliminates error-prone JSON string manipulation 37 | 38 | ### Usage 39 | ```ruby 40 | # Basic usage with schema.rb 41 | RubyLLM.chat.with_template(:extract_metadata, document: @document).complete 42 | 43 | # Context variables available in both ERB and schema.rb 44 | RubyLLM.chat.with_template(:extract_metadata, 45 | document: @document, 46 | categories: ["finance", "technology"], 47 | max_items: 10 48 | ).complete 49 | ``` 50 | 51 | ### Template Structure 52 | ``` 53 | prompts/extract_metadata/ 54 | ├── system.txt.erb # System message 55 | ├── user.txt.erb # User prompt with ERB 56 | ├── assistant.txt.erb # Optional assistant message 57 | └── schema.rb # RubyLLM::Schema definition 58 | ``` 59 | 60 | ### Rails Setup 61 | ```bash 62 | rails generate ruby_llm_template:install 63 | ``` 64 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ruby_llm-template (0.1.6) 5 | ruby_llm (>= 1.0) 6 | ruby_llm-schema (>= 0.2.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | ast (2.4.3) 12 | base64 (0.3.0) 13 | diff-lcs (1.6.2) 14 | event_stream_parser (1.0.0) 15 | faraday (2.13.4) 16 | faraday-net_http (>= 2.0, < 3.5) 17 | json 18 | logger 19 | faraday-multipart (1.1.1) 20 | multipart-post (~> 2.0) 21 | faraday-net_http (3.4.1) 22 | net-http (>= 0.5.0) 23 | faraday-retry (2.3.2) 24 | faraday (~> 2.0) 25 | json (2.13.2) 26 | language_server-protocol (3.17.0.5) 27 | lint_roller (1.1.0) 28 | logger (1.7.0) 29 | marcel (1.0.4) 30 | multipart-post (2.4.1) 31 | net-http (0.6.0) 32 | uri 33 | parallel (1.27.0) 34 | parser (3.3.9.0) 35 | ast (~> 2.4.1) 36 | racc 37 | prism (1.4.0) 38 | racc (1.8.1) 39 | rainbow (3.1.1) 40 | rake (13.3.0) 41 | regexp_parser (2.11.2) 42 | rspec (3.13.1) 43 | rspec-core (~> 3.13.0) 44 | rspec-expectations (~> 3.13.0) 45 | rspec-mocks (~> 3.13.0) 46 | rspec-core (3.13.5) 47 | rspec-support (~> 3.13.0) 48 | rspec-expectations (3.13.5) 49 | diff-lcs (>= 1.2.0, < 2.0) 50 | rspec-support (~> 3.13.0) 51 | rspec-mocks (3.13.5) 52 | diff-lcs (>= 1.2.0, < 2.0) 53 | rspec-support (~> 3.13.0) 54 | rspec-support (3.13.5) 55 | rubocop (1.75.8) 56 | json (~> 2.3) 57 | language_server-protocol (~> 3.17.0.2) 58 | lint_roller (~> 1.1.0) 59 | parallel (~> 1.10) 60 | parser (>= 3.3.0.2) 61 | rainbow (>= 2.2.2, < 4.0) 62 | regexp_parser (>= 2.9.3, < 3.0) 63 | rubocop-ast (>= 1.44.0, < 2.0) 64 | ruby-progressbar (~> 1.7) 65 | unicode-display_width (>= 2.4.0, < 4.0) 66 | rubocop-ast (1.46.0) 67 | parser (>= 3.3.7.2) 68 | prism (~> 1.4) 69 | rubocop-performance (1.25.0) 70 | lint_roller (~> 1.1) 71 | rubocop (>= 1.75.0, < 2.0) 72 | rubocop-ast (>= 1.38.0, < 2.0) 73 | ruby-progressbar (1.13.0) 74 | ruby_llm (1.6.4) 75 | base64 76 | event_stream_parser (~> 1) 77 | faraday (>= 1.10.0) 78 | faraday-multipart (>= 1) 79 | faraday-net_http (>= 1) 80 | faraday-retry (>= 1) 81 | marcel (~> 1.0) 82 | zeitwerk (~> 2) 83 | ruby_llm-schema (0.2.1) 84 | standard (1.50.0) 85 | language_server-protocol (~> 3.17.0.2) 86 | lint_roller (~> 1.0) 87 | rubocop (~> 1.75.5) 88 | standard-custom (~> 1.0.0) 89 | standard-performance (~> 1.8) 90 | standard-custom (1.0.2) 91 | lint_roller (~> 1.0) 92 | rubocop (~> 1.50) 93 | standard-performance (1.8.0) 94 | lint_roller (~> 1.1) 95 | rubocop-performance (~> 1.25.0) 96 | unicode-display_width (3.1.5) 97 | unicode-emoji (~> 4.0, >= 4.0.4) 98 | unicode-emoji (4.0.4) 99 | uri (1.0.3) 100 | zeitwerk (2.7.3) 101 | 102 | PLATFORMS 103 | arm64-darwin-24 104 | ruby 105 | 106 | DEPENDENCIES 107 | rake (~> 13.0) 108 | rspec (~> 3.12) 109 | ruby_llm-template! 110 | standard (~> 1.3) 111 | 112 | BUNDLED WITH 113 | 2.6.2 114 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release & Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: 'Version to release (e.g., 0.1.1)' 11 | required: true 12 | type: string 13 | prerelease: 14 | description: 'Is this a pre-release?' 15 | required: false 16 | default: false 17 | type: boolean 18 | 19 | jobs: 20 | test: 21 | uses: ./.github/workflows/ci.yml 22 | 23 | release: 24 | needs: test 25 | runs-on: ubuntu-latest 26 | permissions: 27 | contents: write 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Set up Ruby 36 | uses: ruby/setup-ruby@v1 37 | with: 38 | ruby-version: '3.2' 39 | bundler-cache: false # Disable cache for release workflow 40 | 41 | - name: Configure Bundler for release 42 | run: bundle config set --local deployment false 43 | 44 | - name: Install dependencies 45 | run: bundle install 46 | 47 | - name: Configure Git 48 | run: | 49 | git config --global user.name "github-actions[bot]" 50 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 51 | 52 | - name: Update version (manual release) 53 | if: github.event_name == 'workflow_dispatch' 54 | run: | 55 | echo "Updating version to ${{ github.event.inputs.version }}" 56 | sed -i 's/VERSION = ".*"/VERSION = "${{ github.event.inputs.version }}"/' lib/ruby_llm/template/version.rb 57 | 58 | # Update Gemfile.lock to reflect the new version 59 | bundle install 60 | 61 | # Commit both the version file and the updated lock file 62 | git add lib/ruby_llm/template/version.rb Gemfile.lock 63 | git commit -m "Bump version to ${{ github.event.inputs.version }}" 64 | git tag "v${{ github.event.inputs.version }}" 65 | git push origin main 66 | git push origin "v${{ github.event.inputs.version }}" 67 | 68 | - name: Extract version from tag 69 | id: version 70 | run: | 71 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 72 | echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT 73 | else 74 | echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 75 | fi 76 | 77 | - name: Build gem 78 | run: | 79 | gem build *.gemspec 80 | echo "GEM_FILE=$(ls *.gem)" >> $GITHUB_ENV 81 | 82 | - name: Publish to RubyGems 83 | env: 84 | RUBYGEMS_AUTH_TOKEN: ${{ secrets.RUBYGEMS_AUTH_TOKEN }} 85 | run: | 86 | mkdir -p ~/.gem 87 | echo ":rubygems_api_key: $RUBYGEMS_AUTH_TOKEN" > ~/.gem/credentials 88 | chmod 0600 ~/.gem/credentials 89 | gem push ${{ env.GEM_FILE }} 90 | 91 | - name: Create GitHub Release 92 | uses: softprops/action-gh-release@v2 93 | env: 94 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 95 | with: 96 | tag_name: v${{ steps.version.outputs.version }} 97 | name: Release v${{ steps.version.outputs.version }} 98 | generate_release_notes: true 99 | prerelease: ${{ github.event.inputs.prerelease == 'true' }} 100 | files: ${{ env.GEM_FILE }} 101 | body: | 102 | ## Installation 103 | 104 | ```bash 105 | gem install ruby_llm-template -v ${{ steps.version.outputs.version }} 106 | ``` 107 | 108 | Or add to your Gemfile: 109 | 110 | ```ruby 111 | gem 'ruby_llm-template', '~> ${{ steps.version.outputs.version }}' 112 | ``` -------------------------------------------------------------------------------- /lib/generators/ruby_llm/template/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | 5 | module RubyLLM 6 | module Template 7 | module Generators 8 | class InstallGenerator < Rails::Generators::Base 9 | namespace "ruby_llm_template:install" 10 | desc "Install RubyLLM Template system" 11 | 12 | def self.source_root 13 | @source_root ||= File.expand_path("templates", __dir__) 14 | end 15 | 16 | def create_initializer 17 | create_file "config/initializers/ruby_llm_template.rb", <<~RUBY 18 | # frozen_string_literal: true 19 | 20 | RubyLLM::Template.configure do |config| 21 | # Set the directory where your prompts are stored 22 | # Default: Rails.root.join("app", "prompts") 23 | # config.template_directory = Rails.root.join("app", "prompts") 24 | end 25 | RUBY 26 | end 27 | 28 | def create_template_directory 29 | empty_directory "app/prompts" 30 | 31 | create_file "app/prompts/.keep", "" 32 | 33 | # Create an example template 34 | create_example_template 35 | end 36 | 37 | def show_readme 38 | say <<~MESSAGE 39 | 40 | RubyLLM Template has been installed! 41 | 42 | Prompts directory: app/prompts/ 43 | Configuration: config/initializers/ruby_llm_template.rb 44 | 45 | Example usage: 46 | RubyLLM.chat.with_template(:extract_metadata, document: @document).complete 47 | 48 | Template structure: 49 | app/prompts/extract_metadata/ 50 | ├── system.txt.erb # System message 51 | ├── user.txt.erb # User prompt 52 | ├── assistant.txt.erb # Assistant message (optional) 53 | └── schema.rb # RubyLLM::Schema definition (optional) 54 | 55 | Get started by creating your first template! 56 | MESSAGE 57 | end 58 | 59 | private 60 | 61 | def create_example_template 62 | example_dir = "app/prompts/extract_metadata" 63 | empty_directory example_dir 64 | 65 | create_file "#{example_dir}/system.txt.erb", <<~ERB 66 | You are an expert document analyzer. Your task is to extract metadata from the provided document. 67 | 68 | Please analyze the document carefully and extract relevant information such as: 69 | - Document type 70 | - Key topics 71 | - Important dates 72 | - Main entities mentioned 73 | 74 | Provide your analysis in a structured format. 75 | ERB 76 | 77 | create_file "#{example_dir}/user.txt.erb", <<~ERB 78 | Please analyze the following document and extract its metadata: 79 | 80 | <% if defined?(document) && document %> 81 | Document: <%= document %> 82 | <% else %> 83 | [Document content will be provided here] 84 | <% end %> 85 | 86 | <% if defined?(additional_context) && additional_context %> 87 | Additional context: <%= additional_context %> 88 | <% end %> 89 | ERB 90 | 91 | create_file "#{example_dir}/schema.rb", <<~RUBY 92 | # frozen_string_literal: true 93 | 94 | # Schema definition using RubyLLM::Schema DSL 95 | # See: https://github.com/danielfriis/ruby_llm-schema 96 | 97 | RubyLLM::Schema.create do 98 | string :document_type, description: "The type of document (e.g., report, article, email)" 99 | 100 | array :key_topics, description: "Main topics discussed in the document" do 101 | string 102 | end 103 | 104 | array :important_dates, required: false, description: "Significant dates mentioned in the document" do 105 | string format: "date" 106 | end 107 | 108 | array :entities, required: false, description: "Named entities found in the document" do 109 | object do 110 | string :name 111 | string :type, enum: ["person", "organization", "location", "other"] 112 | end 113 | end 114 | end 115 | RUBY 116 | end 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/ruby_llm/template/chat_extension_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe RubyLLM::Template::ChatExtension do 6 | let(:chat_double) { double("Chat") } 7 | let(:template_directory) { @tmpdir } 8 | 9 | before do 10 | chat_double.extend(described_class) 11 | 12 | RubyLLM::Template.configure do |config| 13 | config.template_directory = template_directory 14 | end 15 | end 16 | 17 | describe "#with_template" do 18 | context "when template exists" do 19 | before do 20 | create_test_template("test_template", { 21 | system: "You are a helpful assistant.", 22 | user: "Hello, <%= name %>!", 23 | assistant: "Hi there!" 24 | }) 25 | end 26 | 27 | it "adds messages in correct order" do 28 | expect(chat_double).to receive(:add_message).with(role: "system", content: "You are a helpful assistant.") 29 | expect(chat_double).to receive(:add_message).with(role: "user", content: "Hello, Alice!") 30 | expect(chat_double).to receive(:add_message).with(role: "assistant", content: "Hi there!") 31 | 32 | result = chat_double.with_template(:test_template, name: "Alice") 33 | expect(result).to be(chat_double) 34 | end 35 | 36 | it "skips empty content" do 37 | create_test_template("empty_template", { 38 | system: "System message", 39 | user: " \n\t " # Only whitespace 40 | }) 41 | 42 | expect(chat_double).to receive(:add_message).with(role: "system", content: "System message") 43 | expect(chat_double).not_to receive(:add_message).with(hash_including(role: "user")) 44 | 45 | chat_double.with_template(:empty_template) 46 | end 47 | end 48 | 49 | context "when template has schema.rb" do 50 | before do 51 | stub_const("RubyLLM::Schema", Class.new do 52 | def self.create(&block) 53 | instance = new 54 | instance.instance_eval(&block) 55 | instance 56 | end 57 | 58 | def initialize 59 | @properties = {} 60 | end 61 | 62 | def string(name, **options) 63 | @properties[name] = {type: "string"}.merge(options) 64 | end 65 | 66 | def to_json_schema 67 | { 68 | name: "TestSchema", 69 | schema: { 70 | type: "object", 71 | properties: @properties, 72 | required: @properties.keys 73 | } 74 | } 75 | end 76 | end) 77 | 78 | create_test_template("schema_template", { 79 | system: "System message" 80 | }) 81 | 82 | File.write(File.join(template_directory, "schema_template", "schema.rb"), <<~RUBY) 83 | RubyLLM::Schema.create do 84 | string :name, description: "User name" 85 | end 86 | RUBY 87 | end 88 | 89 | it "applies schema using with_schema" do 90 | expect(chat_double).to receive(:add_message).with(role: "system", content: "System message") 91 | expect(chat_double).to receive(:with_schema).with(hash_including( 92 | name: "TestSchema", 93 | schema: hash_including( 94 | type: "object", 95 | properties: hash_including(:name) 96 | ) 97 | )) 98 | 99 | chat_double.with_template(:schema_template) 100 | end 101 | end 102 | 103 | context "when schema.txt.erb exists (should be ignored)" do 104 | before do 105 | create_test_template("ignored_schema", { 106 | system: "System message", 107 | schema: '{"type": "object", "properties": {"name": {"type": "string"}}}' 108 | }) 109 | end 110 | 111 | it "does not call with_schema since schema.txt.erb is ignored" do 112 | expect(chat_double).to receive(:add_message).with(role: "system", content: "System message") 113 | expect(chat_double).not_to receive(:with_schema) 114 | 115 | chat_double.with_template(:ignored_schema) 116 | end 117 | end 118 | 119 | context "when template does not exist" do 120 | it "raises an error" do 121 | expect { 122 | chat_double.with_template(:nonexistent_template) 123 | }.to raise_error(RubyLLM::Template::Error, /Template 'nonexistent_template' not found/) 124 | end 125 | end 126 | 127 | context "when template directory is not configured" do 128 | before do 129 | RubyLLM::Template.configure do |config| 130 | config.template_directory = "/nonexistent/path" 131 | end 132 | end 133 | 134 | it "raises an error" do 135 | expect { 136 | chat_double.with_template(:any_template) 137 | }.to raise_error(RubyLLM::Template::Error, /Template 'any_template' not found/) 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /examples/basic_usage.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Example of using RubyLLM::Template 5 | # This file demonstrates basic usage without actually calling RubyLLM APIs 6 | 7 | require_relative "../lib/ruby_llm/template" 8 | 9 | # Configure template directory 10 | RubyLLM::Template.configure do |config| 11 | config.template_directory = File.join(__dir__, "prompts") 12 | end 13 | 14 | # Create example prompts directory 15 | prompts_dir = File.join(__dir__, "prompts", "extract_metadata") 16 | FileUtils.mkdir_p(prompts_dir) 17 | 18 | # Create example template files 19 | File.write(File.join(prompts_dir, "system.txt.erb"), <<~ERB) 20 | You are an expert document analyzer. Your task is to extract metadata from the provided document. 21 | 22 | Please analyze the document carefully and extract relevant information such as: 23 | - Document type 24 | - Key topics 25 | - Important dates 26 | - Main entities mentioned 27 | 28 | Provide your analysis in a structured format. 29 | ERB 30 | 31 | File.write(File.join(prompts_dir, "user.txt.erb"), <<~ERB) 32 | Please analyze the following document and extract its metadata: 33 | 34 | Document: <%= document %> 35 | 36 | <% if additional_context %> 37 | Additional context: <%= additional_context %> 38 | <% end %> 39 | 40 | Focus areas: <%= focus_areas.join(", ") if defined?(focus_areas) && focus_areas.any? %> 41 | ERB 42 | 43 | # Create schema.rb file using RubyLLM::Schema DSL 44 | File.write(File.join(prompts_dir, "schema.rb"), <<~RUBY) 45 | # Mock RubyLLM::Schema for this example 46 | module RubyLLM 47 | class Schema 48 | def self.create(&block) 49 | instance = new 50 | instance.instance_eval(&block) 51 | instance 52 | end 53 | 54 | def initialize 55 | @schema = {type: "object", properties: {}, required: []} 56 | end 57 | 58 | def string(name, **options) 59 | @schema[:properties][name] = {type: "string"}.merge(options.except(:required)) 60 | @schema[:required] << name unless options[:required] == false 61 | end 62 | 63 | def array(name, **options, &block) 64 | prop = {type: "array"}.merge(options.except(:required)) 65 | prop[:items] = {type: "string"} if !block_given? 66 | @schema[:properties][name] = prop 67 | @schema[:required] << name unless options[:required] == false 68 | end 69 | 70 | def to_json_schema 71 | {name: "ExtractMetadataSchema", schema: @schema} 72 | end 73 | end 74 | end 75 | 76 | # The actual schema definition 77 | RubyLLM::Schema.create do 78 | string :document_type, description: "The type of document (e.g., report, article, email)" 79 | 80 | array :key_topics, description: "Main topics discussed in the document" 81 | 82 | array :important_dates, required: false, description: "Significant dates mentioned" 83 | 84 | # Context variables are available in schema.rb files 85 | focus_count = defined?(focus_areas) ? focus_areas&.length || 3 : 3 86 | end 87 | RUBY 88 | 89 | puts "🎯 RubyLLM::Template Example" 90 | puts "=" * 40 91 | 92 | # Mock chat object that demonstrates the extension 93 | class MockChat 94 | include RubyLLM::Template::ChatExtension 95 | 96 | def initialize 97 | @messages = [] 98 | @schema = nil 99 | end 100 | 101 | def add_message(role:, content:) 102 | @messages << {role: role, content: content} 103 | puts "📝 Added #{role} message: #{content[0..100]}#{"..." if content.length > 100}" 104 | end 105 | 106 | def with_schema(schema) 107 | @schema = schema 108 | if schema.is_a?(Hash) && schema[:schema] 109 | puts "📋 Schema applied: #{schema[:name]} with #{schema[:schema][:properties]&.keys&.length || 0} properties" 110 | else 111 | puts "📋 Schema applied with #{schema.keys.length} properties" 112 | end 113 | self 114 | end 115 | 116 | def complete 117 | puts "\n🤖 Chat would now be sent to AI with:" 118 | puts " - #{@messages.length} messages" 119 | puts " - Schema: #{@schema ? "Yes" : "No"}" 120 | puts "\n💬 Messages:" 121 | @messages.each_with_index do |msg, i| 122 | puts " #{i + 1}. [#{msg[:role].upcase}] #{msg[:content][0..80]}#{"..." if msg[:content].length > 80}" 123 | end 124 | self 125 | end 126 | end 127 | 128 | # Simulate the usage 129 | begin 130 | chat = MockChat.new 131 | 132 | # This demonstrates the desired API: 133 | # RubyLLM.chat.with_template(:extract_metadata, context).complete 134 | chat.with_template(:extract_metadata, 135 | document: "Q3 Financial Report: Revenue increased 15% to $2.3M. Key challenges include supply chain delays affecting Q4 projections.", 136 | additional_context: "Focus on financial metrics and future outlook", 137 | focus_areas: ["revenue", "challenges", "projections"]).complete 138 | rescue RubyLLM::Template::Error => e 139 | puts "❌ Error: #{e.message}" 140 | end 141 | 142 | puts "\n✅ Example completed successfully!" 143 | puts "\nTo use with real RubyLLM:" 144 | puts " RubyLLM.chat.with_template(:extract_metadata, document: @document).complete" 145 | 146 | # Clean up example files 147 | FileUtils.rm_rf(File.join(__dir__, "prompts")) if Dir.exist?(File.join(__dir__, "prompts")) 148 | -------------------------------------------------------------------------------- /lib/ruby_llm/template/loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "erb" 4 | require "pathname" 5 | 6 | begin 7 | require "ruby_llm/schema" 8 | rescue LoadError 9 | # RubyLLM::Schema not available, schema.rb files won't work 10 | end 11 | 12 | module RubyLLM 13 | module Template 14 | class Loader 15 | SUPPORTED_ROLES = %w[system user assistant schema].freeze 16 | 17 | def initialize(template_name, template_directory: nil) 18 | @template_name = template_name.to_s 19 | @template_directory = Pathname.new(template_directory || RubyLLM::Template.configuration.template_directory) 20 | @template_path = @template_directory.join(@template_name) 21 | end 22 | 23 | def render_template(role, context = {}) 24 | return nil unless SUPPORTED_ROLES.include?(role.to_s) 25 | 26 | # Handle schema role specially - only support .rb files 27 | if role.to_s == "schema" 28 | return render_schema_template(context) 29 | end 30 | 31 | # Handle regular ERB template 32 | file_name = "#{role}.txt.erb" 33 | template_file = @template_path.join(file_name) 34 | 35 | return nil unless File.exist?(template_file) 36 | 37 | template_content = File.read(template_file) 38 | erb = ERB.new(template_content) 39 | 40 | # Create a binding with the context variables 41 | binding_context = create_binding_context(context) 42 | erb.result(binding_context) 43 | rescue => e 44 | raise Error, "Failed to render template '#{@template_name}/#{file_name}': #{e.message}" 45 | end 46 | 47 | def available_roles 48 | return [] unless Dir.exist?(@template_path) 49 | 50 | roles = [] 51 | 52 | # Check for ERB templates (excluding schema.txt.erb) 53 | Dir.glob("*.txt.erb", base: @template_path).each do |file| 54 | role = File.basename(file, ".txt.erb") 55 | next if role == "schema" # Skip schema.txt.erb files 56 | roles << role if SUPPORTED_ROLES.include?(role) 57 | end 58 | 59 | # Check for schema.rb file 60 | if File.exist?(@template_path.join("schema.rb")) 61 | roles << "schema" unless roles.include?("schema") 62 | end 63 | 64 | roles.uniq 65 | end 66 | 67 | def template_exists? 68 | Dir.exist?(@template_path) && !available_roles.empty? 69 | end 70 | 71 | def load_schema_class(context = {}) 72 | schema_file = @template_path.join("schema.rb") 73 | return nil unless File.exist?(schema_file) 74 | return nil unless defined?(RubyLLM::Schema) 75 | 76 | schema_content = File.read(schema_file) 77 | schema_context = create_schema_context(context) 78 | 79 | # Evaluate the schema file 80 | result = schema_context.instance_eval(schema_content, schema_file.to_s) 81 | 82 | # Handle different patterns: 83 | # 1. RubyLLM::Schema.create { } pattern - returns instance 84 | if result.is_a?(RubyLLM::Schema) || result.respond_to?(:to_json_schema) 85 | return result 86 | end 87 | 88 | # 2. Class definition pattern - look for TemplateClass::Schema 89 | template_class_name = @template_name.to_s.split("_").map(&:capitalize).join 90 | schema_class_name = "#{template_class_name}::Schema" 91 | 92 | schema_class = constantize_safe(schema_class_name) 93 | return schema_class if schema_class 94 | 95 | raise Error, "Schema file must return a RubyLLM::Schema instance or define class '#{schema_class_name}'" 96 | rescue => e 97 | raise Error, "Failed to load schema from '#{@template_name}/schema.rb': #{e.message}" 98 | end 99 | 100 | private 101 | 102 | def render_schema_template(context = {}) 103 | # Only support schema.rb files with RubyLLM::Schema 104 | schema_instance = load_schema_class(context) 105 | return schema_instance if schema_instance 106 | 107 | # If there's a schema.rb file but RubyLLM::Schema isn't available, error 108 | schema_file = @template_path.join("schema.rb") 109 | if File.exist?(schema_file) && !defined?(RubyLLM::Schema) 110 | raise Error, "Schema file '#{@template_name}/schema.rb' found but RubyLLM::Schema gem is not installed. Add 'gem \"ruby_llm-schema\"' to your Gemfile." 111 | end 112 | 113 | nil 114 | end 115 | 116 | def create_binding_context(context) 117 | # Create a new binding with the context variables available 118 | context.each do |key, value| 119 | define_singleton_method(key) { value } 120 | end 121 | 122 | binding 123 | end 124 | 125 | def create_schema_context(context) 126 | schema_context = Object.new 127 | context.each do |key, value| 128 | schema_context.instance_variable_set("@#{key}", value) 129 | schema_context.define_singleton_method(key) { value } 130 | end 131 | schema_context 132 | end 133 | 134 | def constantize_safe(class_name) 135 | if defined?(Rails) 136 | class_name.constantize 137 | else 138 | # Simple constantize for non-Rails environments 139 | Object.const_get(class_name) 140 | end 141 | rescue NameError 142 | nil 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /spec/ruby_llm/template/schema_only_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Schema-only Support" do 6 | let(:template_name) { "test_template" } 7 | let(:template_directory) { @tmpdir } 8 | let(:loader) { RubyLLM::Template::Loader.new(template_name, template_directory: template_directory) } 9 | 10 | before do 11 | RubyLLM::Template.configure do |config| 12 | config.template_directory = template_directory 13 | end 14 | end 15 | 16 | describe "schema.rb without RubyLLM::Schema gem" do 17 | before do 18 | # Ensure RubyLLM::Schema is not defined 19 | hide_const("RubyLLM::Schema") if defined?(RubyLLM::Schema) 20 | 21 | create_test_template(template_name, { 22 | system: "System message" 23 | }) 24 | 25 | File.write(File.join(template_directory, template_name, "schema.rb"), <<~RUBY) 26 | RubyLLM::Schema.create do 27 | string :name 28 | end 29 | RUBY 30 | end 31 | 32 | it "raises error when schema.rb exists but gem is not installed" do 33 | expect { 34 | loader.render_template("schema") 35 | }.to raise_error( 36 | RubyLLM::Template::Error, 37 | /Schema file 'test_template\/schema.rb' found but RubyLLM::Schema gem is not installed/ 38 | ) 39 | end 40 | 41 | it "includes schema in available_roles even without gem" do 42 | expect(loader.available_roles).to include("schema") 43 | end 44 | 45 | it "template_exists? returns true even with schema.rb without gem" do 46 | expect(loader.template_exists?).to be true 47 | end 48 | end 49 | 50 | describe "schema.txt.erb files are ignored" do 51 | before do 52 | create_test_template(template_name, { 53 | system: "System message", 54 | schema: '{"type": "object", "properties": {"old": {"type": "string"}}}' 55 | }) 56 | end 57 | 58 | it "does not include schema.txt.erb in available_roles" do 59 | expect(loader.available_roles).to contain_exactly("system") 60 | expect(loader.available_roles).not_to include("schema") 61 | end 62 | 63 | it "returns nil when trying to render schema.txt.erb" do 64 | result = loader.render_template("schema") 65 | expect(result).to be_nil 66 | end 67 | end 68 | 69 | describe "Chat extension with schema.rb only" do 70 | let(:chat_double) { double("Chat") } 71 | 72 | before do 73 | chat_double.extend(RubyLLM::Template::ChatExtension) 74 | end 75 | 76 | context "when schema.rb exists but gem not installed" do 77 | before do 78 | hide_const("RubyLLM::Schema") if defined?(RubyLLM::Schema) 79 | 80 | create_test_template(template_name, { 81 | system: "System message" 82 | }) 83 | 84 | File.write(File.join(template_directory, template_name, "schema.rb"), "RubyLLM::Schema.create { }") 85 | end 86 | 87 | it "raises error during with_template call" do 88 | expect(chat_double).to receive(:add_message).with(role: "system", content: "System message") 89 | 90 | expect { 91 | chat_double.with_template(template_name.to_sym) 92 | }.to raise_error( 93 | RubyLLM::Template::Error, 94 | /Schema file.*found but RubyLLM::Schema gem is not installed/ 95 | ) 96 | end 97 | end 98 | 99 | context "when only schema.txt.erb exists" do 100 | before do 101 | create_test_template(template_name, { 102 | system: "System message", 103 | schema: '{"type": "object"}' 104 | }) 105 | end 106 | 107 | it "does not call with_schema since schema.txt.erb is ignored" do 108 | expect(chat_double).to receive(:add_message).with(role: "system", content: "System message") 109 | expect(chat_double).not_to receive(:with_schema) 110 | 111 | chat_double.with_template(template_name.to_sym) 112 | end 113 | end 114 | end 115 | 116 | describe "mixed schema files" do 117 | before do 118 | stub_const("RubyLLM::Schema", Class.new do 119 | def self.create(&block) 120 | instance = new 121 | instance.instance_eval(&block) 122 | instance 123 | end 124 | 125 | def initialize 126 | @properties = {} 127 | end 128 | 129 | def string(name, **options) 130 | @properties[name] = {type: "string"}.merge(options) 131 | end 132 | 133 | def to_json_schema 134 | {name: "TestSchema", schema: {type: "object", properties: @properties}} 135 | end 136 | end) 137 | 138 | create_test_template(template_name, { 139 | system: "System message", 140 | schema: '{"type": "object", "properties": {"old_field": {"type": "string"}}}' 141 | }) 142 | 143 | # Add schema.rb file 144 | File.write(File.join(template_directory, template_name, "schema.rb"), <<~RUBY) 145 | RubyLLM::Schema.create do 146 | string :new_field, description: "New field from .rb file" 147 | end 148 | RUBY 149 | end 150 | 151 | it "only uses schema.rb and ignores schema.txt.erb" do 152 | result = loader.render_template("schema") 153 | 154 | expect(result).to respond_to(:to_json_schema) 155 | schema_data = result.to_json_schema 156 | expect(schema_data[:schema][:properties]).to include(:new_field) 157 | expect(schema_data[:schema][:properties]).not_to include(:old_field) 158 | end 159 | 160 | it "available_roles only includes schema once" do 161 | expect(loader.available_roles).to contain_exactly("system", "schema") 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /spec/ruby_llm/template/loader_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe RubyLLM::Template::Loader do 6 | let(:template_name) { "test_template" } 7 | let(:template_directory) { @tmpdir } 8 | let(:loader) { described_class.new(template_name, template_directory: template_directory) } 9 | 10 | describe "#render_template" do 11 | context "when template file exists" do 12 | before do 13 | create_test_template(template_name, { 14 | system: "You are a helpful assistant.", 15 | user: "Hello, <%= name %>! How are you today?" 16 | }) 17 | end 18 | 19 | it "renders system template without context" do 20 | result = loader.render_template("system") 21 | expect(result).to eq("You are a helpful assistant.") 22 | end 23 | 24 | it "renders user template with context" do 25 | result = loader.render_template("user", name: "Alice") 26 | expect(result).to eq("Hello, Alice! How are you today?") 27 | end 28 | end 29 | 30 | context "when template file does not exist" do 31 | it "returns nil" do 32 | result = loader.render_template("nonexistent") 33 | expect(result).to be_nil 34 | end 35 | end 36 | 37 | context "when schema.rb file exists" do 38 | before do 39 | # Mock RubyLLM::Schema availability 40 | stub_const("RubyLLM::Schema", Class.new do 41 | def self.create(&block) 42 | schema_instance = new 43 | schema_instance.instance_eval(&block) if block_given? 44 | schema_instance 45 | end 46 | 47 | def initialize 48 | @properties = {} 49 | end 50 | 51 | def string(name, **options) 52 | @properties[name] = {type: "string"}.merge(options) 53 | end 54 | 55 | def to_json_schema 56 | { 57 | type: "object", 58 | properties: @properties, 59 | required: @properties.keys 60 | } 61 | end 62 | end) 63 | 64 | schema_content = <<~RUBY 65 | RubyLLM::Schema.create do 66 | string :name, description: "Person's name" 67 | string :email, description: "Email address" 68 | end 69 | RUBY 70 | 71 | create_test_template(template_name, {}) 72 | File.write(File.join(template_directory, template_name, "schema.rb"), schema_content) 73 | end 74 | 75 | it "loads and returns schema instance" do 76 | result = loader.render_template("schema") 77 | expect(result).to respond_to(:to_json_schema) 78 | 79 | schema_data = result.to_json_schema 80 | expect(schema_data[:type]).to eq("object") 81 | expect(schema_data[:properties]).to include(:name, :email) 82 | end 83 | 84 | it "includes schema in available_roles" do 85 | expect(loader.available_roles).to include("schema") 86 | end 87 | end 88 | 89 | context "when role is not supported" do 90 | before do 91 | create_test_template(template_name, {invalid: "Invalid role content"}) 92 | end 93 | 94 | it "returns nil for unsupported role" do 95 | result = loader.render_template("invalid") 96 | expect(result).to be_nil 97 | end 98 | end 99 | 100 | context "when ERB template has errors" do 101 | before do 102 | create_test_template(template_name, {system: "<%= undefined_variable %>"}) 103 | end 104 | 105 | it "raises an error with descriptive message" do 106 | expect { 107 | loader.render_template("system") 108 | }.to raise_error(RubyLLM::Template::Error, /Failed to render template/) 109 | end 110 | end 111 | end 112 | 113 | describe "#available_roles" do 114 | context "when template directory exists with valid templates" do 115 | before do 116 | create_test_template(template_name, { 117 | system: "System content", 118 | user: "User content", 119 | invalid: "Invalid content" 120 | }) 121 | end 122 | 123 | it "returns only supported roles" do 124 | expect(loader.available_roles).to contain_exactly("system", "user") 125 | end 126 | end 127 | 128 | context "when template directory has schema.txt.erb file" do 129 | before do 130 | create_test_template(template_name, { 131 | system: "System content", 132 | schema: '{"type": "object"}' 133 | }) 134 | end 135 | 136 | it "excludes schema.txt.erb from available roles" do 137 | expect(loader.available_roles).to contain_exactly("system") 138 | expect(loader.available_roles).not_to include("schema") 139 | end 140 | end 141 | 142 | context "when template directory has schema.rb file" do 143 | before do 144 | create_test_template(template_name, {system: "System content"}) 145 | File.write(File.join(template_directory, template_name, "schema.rb"), "# Schema") 146 | end 147 | 148 | it "includes schema in available roles" do 149 | expect(loader.available_roles).to contain_exactly("system", "schema") 150 | end 151 | end 152 | 153 | context "when template directory does not exist" do 154 | it "returns empty array" do 155 | expect(loader.available_roles).to eq([]) 156 | end 157 | end 158 | end 159 | 160 | describe "#template_exists?" do 161 | context "when template directory exists with templates" do 162 | before do 163 | create_test_template(template_name, {system: "Content"}) 164 | end 165 | 166 | it "returns true" do 167 | expect(loader.template_exists?).to be true 168 | end 169 | end 170 | 171 | context "when template directory does not exist" do 172 | it "returns false" do 173 | expect(loader.template_exists?).to be false 174 | end 175 | end 176 | 177 | context "when template directory exists but has no valid templates" do 178 | before do 179 | FileUtils.mkdir_p(File.join(template_directory, template_name)) 180 | end 181 | 182 | it "returns false" do 183 | expect(loader.template_exists?).to be false 184 | end 185 | end 186 | end 187 | 188 | describe "SUPPORTED_ROLES" do 189 | it "includes expected roles" do 190 | expect(described_class::SUPPORTED_ROLES).to include("system", "user", "assistant", "schema") 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /spec/ruby_llm/template/schema_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "Schema Integration" do 6 | let(:template_name) { "test_template" } 7 | let(:template_directory) { @tmpdir } 8 | let(:loader) { RubyLLM::Template::Loader.new(template_name, template_directory: template_directory) } 9 | 10 | before do 11 | RubyLLM::Template.configure do |config| 12 | config.template_directory = template_directory 13 | end 14 | end 15 | 16 | describe "RubyLLM::Schema integration" do 17 | context "when RubyLLM::Schema is available" do 18 | before do 19 | # Mock RubyLLM::Schema 20 | stub_const("RubyLLM::Schema", Class.new do 21 | def self.create(&block) 22 | instance = new 23 | instance.instance_eval(&block) if block_given? 24 | instance 25 | end 26 | 27 | def initialize 28 | @properties = {} 29 | end 30 | 31 | def string(name, **options) 32 | @properties[name] = {type: "string"}.merge(options) 33 | end 34 | 35 | def number(name, **options) 36 | @properties[name] = {type: "number"}.merge(options) 37 | end 38 | 39 | def array(name, of: nil, **options, &block) 40 | property = {type: "array"}.merge(options) 41 | property[:items] = {type: of.to_s} if of 42 | @properties[name] = property 43 | end 44 | 45 | def to_json_schema 46 | { 47 | name: "TestSchema", 48 | schema: { 49 | type: "object", 50 | properties: @properties, 51 | required: @properties.keys, 52 | additionalProperties: false 53 | } 54 | } 55 | end 56 | end) 57 | end 58 | 59 | context "with schema.rb file" do 60 | before do 61 | create_test_template(template_name, { 62 | system: "You are a helpful assistant.", 63 | user: "Process this: <%= input %>" 64 | }) 65 | 66 | schema_content = <<~RUBY 67 | RubyLLM::Schema.create do 68 | string :title, description: "Document title" 69 | string :summary, description: "Brief summary" 70 | array :tags, of: :string, description: "Topic tags" 71 | number :confidence, description: "Confidence score" 72 | end 73 | RUBY 74 | 75 | File.write(File.join(template_directory, template_name, "schema.rb"), schema_content) 76 | end 77 | 78 | it "loads schema from .rb file" do 79 | schema = loader.load_schema_class(input: "test document") 80 | 81 | expect(schema).to respond_to(:to_json_schema) 82 | schema_data = schema.to_json_schema 83 | 84 | expect(schema_data[:schema][:type]).to eq("object") 85 | expect(schema_data[:schema][:properties]).to include(:title, :summary, :tags, :confidence) 86 | end 87 | 88 | it "makes context variables available in schema" do 89 | # Test that context variables can be accessed (though not used in this simple example) 90 | expect { 91 | loader.load_schema_class(input: "test document", max_tags: 5) 92 | }.not_to raise_error 93 | end 94 | end 95 | 96 | context "with both schema.rb and schema.txt.erb" do 97 | before do 98 | create_test_template(template_name, { 99 | system: "System message", 100 | schema: '{"type": "object", "properties": {"old": {"type": "string"}}}' 101 | }) 102 | 103 | schema_rb_content = <<~RUBY 104 | RubyLLM::Schema.create do 105 | string :new_field, description: "New field from .rb file" 106 | end 107 | RUBY 108 | 109 | File.write(File.join(template_directory, template_name, "schema.rb"), schema_rb_content) 110 | end 111 | 112 | it "uses schema.rb and ignores schema.txt.erb" do 113 | result = loader.render_template("schema") 114 | 115 | expect(result).to respond_to(:to_json_schema) 116 | schema_data = result.to_json_schema 117 | expect(schema_data[:schema][:properties]).to include(:new_field) 118 | expect(schema_data[:schema][:properties]).not_to include(:old) 119 | end 120 | 121 | it "available_roles only includes schema once" do 122 | expect(loader.available_roles).to contain_exactly("system", "schema") 123 | end 124 | end 125 | end 126 | 127 | context "when RubyLLM::Schema is not available" do 128 | before do 129 | # Ensure RubyLLM::Schema is not defined 130 | hide_const("RubyLLM::Schema") if defined?(RubyLLM::Schema) 131 | 132 | create_test_template(template_name, { 133 | system: "System message" 134 | }) 135 | 136 | File.write(File.join(template_directory, template_name, "schema.rb"), "# Schema content") 137 | end 138 | 139 | it "raises error when schema.rb exists but gem not installed" do 140 | expect { 141 | loader.render_template("schema") 142 | }.to raise_error( 143 | RubyLLM::Template::Error, 144 | /Schema file.*found but RubyLLM::Schema gem is not installed/ 145 | ) 146 | end 147 | end 148 | end 149 | 150 | describe "Chat Extension with Schema" do 151 | let(:chat_double) { double("Chat") } 152 | 153 | before do 154 | chat_double.extend(RubyLLM::Template::ChatExtension) 155 | end 156 | 157 | context "with schema.rb file" do 158 | before do 159 | stub_const("RubyLLM::Schema", Class.new do 160 | def self.create(&block) 161 | instance = new 162 | instance.instance_eval(&block) 163 | instance 164 | end 165 | 166 | def initialize 167 | @properties = {} 168 | end 169 | 170 | def string(name, **options) 171 | @properties[name] = {type: "string"}.merge(options) 172 | end 173 | 174 | def to_json_schema 175 | { 176 | name: "TestSchema", 177 | schema: { 178 | type: "object", 179 | properties: @properties, 180 | required: @properties.keys 181 | } 182 | } 183 | end 184 | end) 185 | 186 | create_test_template(template_name, { 187 | system: "System message" 188 | }) 189 | 190 | schema_content = <<~RUBY 191 | RubyLLM::Schema.create do 192 | string :result, description: "Processing result" 193 | end 194 | RUBY 195 | 196 | File.write(File.join(template_directory, template_name, "schema.rb"), schema_content) 197 | end 198 | 199 | it "applies schema from .rb file using with_schema" do 200 | expect(chat_double).to receive(:add_message).with(role: "system", content: "System message") 201 | expect(chat_double).to receive(:with_schema).with(hash_including( 202 | name: "TestSchema", 203 | schema: hash_including( 204 | type: "object", 205 | properties: hash_including(:result) 206 | ) 207 | )) 208 | 209 | chat_double.with_template(template_name.to_sym) 210 | end 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RubyLLM::Template 2 | 3 | [![Gem Version](https://badge.fury.io/rb/ruby_llm-template.svg)](https://rubygems.org/gems/ruby_llm-template) 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/danielfriis/ruby_llm-template/blob/main/LICENSE.txt) 5 | [![CI](https://github.com/danielfriis/ruby_llm-template/actions/workflows/ci.yml/badge.svg)](https://github.com/danielfriis/ruby_llm-template/actions/workflows/ci.yml) 6 | 7 | Organize prompts into easy-to-use templates for [RubyLLM](https://github.com/crmne/ruby_llm). 8 | 9 | ```ruby 10 | # prompts/ 11 | # extract_metadata/ 12 | # ├── system.txt.erb # System message 13 | # ├── user.txt.erb # User prompt 14 | # ├── assistant.txt.erb # Assistant message (optional) 15 | # └── schema.rb # RubyLLM::Schema definition (optional) 16 | 17 | chat = RubyLLM.chat 18 | chat.with_template(:extract_metadata, document: @document).complete 19 | ``` 20 | 21 | ## Features 22 | 23 | - 🎯 **Organized Templates**: Structure your prompts in folders with separate files for system, user, assistant, and schema messages 24 | - 🔄 **ERB Templating**: Use full ERB power with context variables and Ruby logic 25 | - ⚙️ **Configurable**: Set custom template directories per environment 26 | - 🚀 **Rails Integration**: Seamless Rails integration with generators and automatic configuration 27 | - 🧪 **Well Tested**: Comprehensive test suite ensuring reliability 28 | - 📦 **Minimal Dependencies**: Only depends on RubyLLM and standard Ruby libraries 29 | 30 | ## Installation 31 | 32 | Add this line to your application's Gemfile: 33 | 34 | ```ruby 35 | gem 'ruby_llm' 36 | gem 'ruby_llm-template' 37 | gem 'ruby_llm-schema' # Optional for schema.rb support 38 | ``` 39 | 40 | And then execute: 41 | 42 | ```bash 43 | bundle install 44 | ``` 45 | 46 | ### Rails Setup 47 | 48 | If you're using Rails, run the generator to set up the template system: 49 | 50 | ```bash 51 | rails generate ruby_llm_template:install 52 | ``` 53 | 54 | This will: 55 | - Create `config/initializers/ruby_llm_template.rb` 56 | - Create `app/prompts/` directory 57 | - Generate an example template at `app/prompts/extract_metadata/` 58 | 59 | ## Quick Start 60 | 61 | ### 1. Create a Template 62 | 63 | Create a directory structure like this: 64 | 65 | ``` 66 | prompts/ 67 | extract_metadata/ 68 | ├── system.txt.erb # System message 69 | ├── user.txt.erb # User prompt 70 | ├── assistant.txt.erb # Assistant message (optional) 71 | └── schema.rb # RubyLLM::Schema definition (optional) 72 | ``` 73 | 74 | ### 2. Write Your Templates 75 | 76 | **`prompts/extract_metadata/system.txt.erb`**: 77 | ```erb 78 | You are an expert document analyzer. Extract metadata from documents in a structured format. 79 | ``` 80 | 81 | **`prompts/extract_metadata/user.txt.erb`**: 82 | ```erb 83 | Please analyze this document: <%= document %> 84 | 85 | <% if additional_context %> 86 | Additional context: <%= additional_context %> 87 | <% end %> 88 | ``` 89 | 90 | **`prompts/extract_metadata/schema.rb`**: 91 | ```ruby 92 | # Using RubyLLM::Schema DSL for clean, type-safe schemas 93 | RubyLLM::Schema.create do 94 | string :title, description: "Document title" 95 | array :topics, description: "Main topics" do 96 | string 97 | end 98 | string :summary, description: "Brief summary" 99 | 100 | # Optional fields with validation 101 | number :confidence, required: false, minimum: 0, maximum: 1 102 | 103 | # Nested objects 104 | object :metadata, required: false do 105 | string :author 106 | string :date, format: "date" 107 | end 108 | end 109 | ``` 110 | 111 | ### 3. Use the Template 112 | 113 | ```ruby 114 | chat = RubyLLM.chat 115 | chat.with_template(:extract_metadata, document: @document, additional_context: "Focus on technical details").complete 116 | 117 | # Under the hood, RubyLLM::Template renders the templates with the context variables 118 | # and applies them to the chat instance using native RubyLLM methods: 119 | 120 | # chat.add_message(:system, rendered_system_message) 121 | # chat.add_message(:user, rendered_user_message) 122 | # chat.add_schema(instantiated_schema) 123 | ``` 124 | 125 | 126 | ## Configuration 127 | 128 | ### Non-Rails Applications 129 | 130 | ```ruby 131 | RubyLLM::Template.configure do |config| 132 | config.template_directory = "/path/to/your/prompts" 133 | end 134 | ``` 135 | 136 | ### Rails Applications 137 | 138 | The gem automatically configures itself to use `Rails.root.join("app", "prompts")`, but you can override this in `config/initializers/ruby_llm_template.rb`: 139 | 140 | ```ruby 141 | RubyLLM::Template.configure do |config| 142 | config.template_directory = Rails.root.join("app", "ai_prompts") 143 | end 144 | ``` 145 | 146 | ## Template Structure 147 | 148 | Each template is a directory containing ERB files for different message roles: 149 | 150 | - **`system.txt.erb`** - System message that sets the AI's behavior 151 | - **`user.txt.erb`** - User message/prompt 152 | - **`assistant.txt.erb`** - Pre-filled assistant message (optional) 153 | - **`schema.rb`** - RubyLLM::Schema definition for structured output (optional) 154 | 155 | Templates are processed in order: system → user → assistant → schema 156 | 157 | ## ERB Context 158 | 159 | All context variables passed to `with_template` are available in your ERB templates: 160 | 161 | ```ruby 162 | chat = RubyLLM.chat 163 | chat.with_template(:message, name: "Alice", urgent: true, documents: @documents) 164 | ``` 165 | 166 | ```erb 167 | 168 | Hello <%= name %>! 169 | 170 | <% if urgent %> 171 | 🚨 URGENT: <%= message %> 172 | <% else %> 173 | 📋 Regular: <%= message %> 174 | <% end %> 175 | 176 | Processing <%= documents.length %> documents: 177 | <% documents.each_with_index do |doc, i| %> 178 | <%= i + 1 %>. <%= doc.title %> 179 | <% end %> 180 | ``` 181 | 182 | ## Advanced Usage 183 | 184 | ### Complex Templates 185 | 186 | ```ruby 187 | # Template with conditional logic and loops 188 | RubyLLM.chat.with_template(:analyze_reports, 189 | reports: @reports, 190 | priority: "high", 191 | include_charts: true, 192 | deadline: 1.week.from_now 193 | ).complete 194 | ``` 195 | 196 | ### Multiple Template Calls 197 | 198 | ```ruby 199 | chat = RubyLLM.chat 200 | .with_template(:initialize_session, user: current_user) 201 | .with_template(:load_context, project: @project) 202 | 203 | # Add more messages dynamically 204 | chat.ask("What should we focus on first?") 205 | ``` 206 | 207 | ### Schema Definition with RubyLLM::Schema 208 | 209 | The gem integrates with [RubyLLM::Schema](https://github.com/danielfriis/ruby_llm-schema) to provide a clean Ruby DSL for defining JSON schemas. Use `schema.rb` files instead of JSON: 210 | 211 | ```ruby 212 | # prompts/analyze_results/schema.rb 213 | RubyLLM::Schema.create do 214 | number :confidence, minimum: 0, maximum: 1, description: "Analysis confidence" 215 | 216 | array :results, description: "Analysis results" do 217 | object do 218 | string :item, description: "Result item" 219 | number :score, minimum: 0, maximum: 100, description: "Item score" 220 | 221 | # Context variables are available 222 | string :category, enum: categories if defined?(categories) 223 | end 224 | end 225 | 226 | # Optional nested structures 227 | object :metadata, required: false do 228 | string :model_version 229 | string :timestamp, format: "date-time" 230 | end 231 | end 232 | ``` 233 | 234 | **Benefits of `schema.rb`:** 235 | - 🎯 **Type-safe**: Ruby DSL with built-in validation 236 | - 🔄 **Dynamic**: Access template context variables for conditional schemas 237 | - 📝 **Readable**: Clean, self-documenting Ruby syntax vs JSON 238 | - 🔧 **Flexible**: Generate schemas based on runtime conditions 239 | - 🚀 **No JSON**: Eliminate error-prone JSON string manipulation 240 | 241 | **Schema-Only Approach**: The gem exclusively supports `schema.rb` files with RubyLLM::Schema. If you have a `schema.rb` file but the gem isn't installed, you'll get a clear error message. 242 | 243 | ## Error Handling 244 | 245 | The gem provides clear error messages for common issues: 246 | 247 | ```ruby 248 | begin 249 | RubyLLM.chat.with_template(:extract_metadata).complete 250 | rescue RubyLLM::Template::Error => e 251 | puts e.message 252 | # "Template 'extract_metadata' not found in /path/to/prompts" 253 | # "Schema file 'extract_metadata/schema.rb' found but RubyLLM::Schema gem is not installed" 254 | end 255 | ``` 256 | 257 | ## Development 258 | 259 | After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 260 | 261 | To run the test suite: 262 | 263 | ```bash 264 | bundle exec rspec 265 | ``` 266 | 267 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 268 | 269 | ## Contributing 270 | 271 | Bug reports and pull requests are welcome on GitHub at https://github.com/danielfriis/ruby_llm-template. 272 | 273 | ## License 274 | 275 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 276 | --------------------------------------------------------------------------------