From dd7d13f7658eda5ad4db4338181a273143ac8c4a Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 00:52:15 +0500 Subject: [PATCH 01/23] confirm rubyllm supports groq via openai-compatible route --- docs/PROJECT_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 25e67f3..cc5fb68 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -255,7 +255,7 @@ The project choice is locked. New project plans from other tools are not conside - [ ] Olist as the base data, or fully generated data only - [ ] Licenses of the Twitter support dataset checked (Olist done: CC BY-NC-SA 4.0; Bitext done: CDLA-Sharing-1.0, text at https://cdla.dev/sharing-1-0/, use and results are unrestricted, publishing the data itself requires the same license and credit) - [ ] Groq free-tier limits checked in the Groq console -- [ ] RubyLLM support for Groq confirmed (native or OpenAI-compatible base URL) +- [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Whether a given Groq model supports structured output is tested in the console in lesson 2 - [ ] RubyLLM support for Ollama embeddings confirmed - [ ] Blog platform chosen (DEV.to suggested) From fb9b552583626a315432dfaa830dd1a36c766265 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 01:41:22 +0500 Subject: [PATCH 02/23] add dotenv-rails and env example --- .env.example | 3 +++ .gitignore | 3 +++ Gemfile | 3 +++ 3 files changed, 9 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a9a0ff --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and fill in real keys. .env is git-ignored. +GROQ_API_KEY=your_key_here +GEMINI_API_KEY=your_key_here diff --git a/.gitignore b/.gitignore index 7824396..be804e9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ # Ignore all environment files. /.env* +# Keep the example file with placeholder keys. +!/.env.example + # Ignore all logfiles and tempfiles. /log/* /tmp/* diff --git a/Gemfile b/Gemfile index 8a6a570..51dab8e 100644 --- a/Gemfile +++ b/Gemfile @@ -42,6 +42,9 @@ gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" +# Load API keys from .env +gem "dotenv-rails", "~> 3.2" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" From 5b442b662b3ef8f6531fc02ddcde035a68ec329a Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 18:16:13 +0500 Subject: [PATCH 03/23] update gemfile.lock for dotenv-rails --- Gemfile.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index ff6ed30..7b72566 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,6 +109,9 @@ GEM reline (>= 0.3.8) diff-lcs (1.6.2) dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) drb (2.2.3) ed25519 (1.4.0) erb (6.0.7) @@ -421,6 +424,7 @@ DEPENDENCIES capybara csv debug + dotenv-rails (~> 3.2) factory_bot_rails faker image_processing (~> 1.2) @@ -476,6 +480,7 @@ CHECKSUMS debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + dotenv-rails (3.2.0) drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 From 82d786a5c57d1d0500b30f37bfbb03a4ac48251f Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 18:24:57 +0500 Subject: [PATCH 04/23] add ruby_llm and note groq setup for 2.x --- Gemfile | 1 + Gemfile.lock | 36 +++++++++++++++++++++++++++++++++++- docs/PROJECT_PLAN.md | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 51dab8e..abb4ad9 100644 --- a/Gemfile +++ b/Gemfile @@ -74,3 +74,4 @@ group :test do end gem "csv" +gem "ruby_llm" diff --git a/Gemfile.lock b/Gemfile.lock index 7b72566..4d868d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -125,6 +125,16 @@ GEM railties (>= 6.1.0) faker (3.8.0) i18n (>= 1.8.11, < 2) + faraday (2.14.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) @@ -187,6 +197,10 @@ GEM drb (~> 2.0) prism (~> 1.5) msgpack (1.8.5) + multipart-post (2.4.1) + net-http (0.10.0) + net-protocol (>= 0.3.0) + uri (>= 0.11.1) net-imap (0.6.7) date net-protocol @@ -338,7 +352,18 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger + ruby_llm (2.1.0) + base64 + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + json (< 4) + marcel (>= 1.0, < 3) + schematist (~> 1.1) + zeitwerk (~> 2) rubyzip (3.7.0) + schematist (1.1.0) securerandom (0.4.1) selenium-webdriver (4.50.0) base64 (~> 0.2) @@ -437,6 +462,7 @@ DEPENDENCIES rails (~> 8.1.4) rspec-rails rubocop-rails-omakase + ruby_llm selenium-webdriver solid_cable solid_cache @@ -480,7 +506,7 @@ CHECKSUMS debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d - dotenv-rails (3.2.0) + dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 @@ -489,6 +515,10 @@ CHECKSUMS factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc + faraday (2.14.4) sha256=9bb4408c44621b0dbaaaa39e5dbfc4dbe58cc6751b6eb94d16ae9e1f2f2fab6e + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 @@ -516,6 +546,8 @@ CHECKSUMS mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 msgpack (1.8.5) sha256=97419eaa7b35dfe3e462c0e6c1a3dfdcbfc8756605c09e46e4c6daef127517ba + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27 net-imap (0.6.7) sha256=b5c9573be975d856de252ee851871724da66aa2d449b2482d5690bd12bc23660 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853 @@ -572,7 +604,9 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + ruby_llm (2.1.0) sha256=041e73588758172e37b2883584b3bbca6fbddec2f273cdbdfa5f2b4f2f9a053e rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121 + schematist (1.1.0) sha256=905d8f0286830926e77e0d631bee325ee741c4b99640bc99735593b40ba73e7b securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.50.0) sha256=d12f94025bc33af44428ed6b8def4896a7bb9c4521212579241650d6cb060fcb solid_cable (4.1.0) sha256=828e3d509207b82b4f05f4e9c85f696994710679b52d511aa7f97ebd212d3226 diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index cc5fb68..71215a5 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -255,7 +255,7 @@ The project choice is locked. New project plans from other tools are not conside - [ ] Olist as the base data, or fully generated data only - [ ] Licenses of the Twitter support dataset checked (Olist done: CC BY-NC-SA 4.0; Bitext done: CDLA-Sharing-1.0, text at https://cdla.dev/sharing-1-0/, use and results are unrestricted, publishing the data itself requires the same license and credit) - [ ] Groq free-tier limits checked in the Groq console -- [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Whether a given Groq model supports structured output is tested in the console in lesson 2 +- [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Verified in the console on 2026-10-09 with ruby_llm 2.1.0: also pass `protocol: :chat_completions`, because 2.x defaults OpenAI to the Responses API and Groq rejects its `include` field. `llama-3.3-70b-versatile` is gone from Groq; `openai/gpt-oss-120b` answers. Whether a given Groq model supports structured output is tested in the console in lesson 2 - [ ] RubyLLM support for Ollama embeddings confirmed - [ ] Blog platform chosen (DEV.to suggested) From 2329dd8c06ff13d425069e843d1b7c1db3845650 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 18:29:26 +0500 Subject: [PATCH 05/23] configure ruby_llm for groq in an initializer --- config/initializers/ruby_llm.rb | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 config/initializers/ruby_llm.rb diff --git a/config/initializers/ruby_llm.rb b/config/initializers/ruby_llm.rb new file mode 100644 index 0000000..980eea2 --- /dev/null +++ b/config/initializers/ruby_llm.rb @@ -0,0 +1,5 @@ +RubyLLM.configure do |config| + config.openai_api_key = ENV["GROQ_API_KEY"] + config.openai_api_base = "https://api.groq.com/openai/v1" + config.default_model = "openai/gpt-oss-120b" +end From 0ebb6347fe420a1738e4e4996ebcaa7aecabb619 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 18:48:56 +0500 Subject: [PATCH 06/23] rename ruby_llm initializer to ruby_llm_groq --- config/initializers/{ruby_llm.rb => ruby_llm_groq.rb} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename config/initializers/{ruby_llm.rb => ruby_llm_groq.rb} (100%) diff --git a/config/initializers/ruby_llm.rb b/config/initializers/ruby_llm_groq.rb similarity index 100% rename from config/initializers/ruby_llm.rb rename to config/initializers/ruby_llm_groq.rb From 9e0801f08aef1bc89a2bb28ca8191afeee268013 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 18:49:10 +0500 Subject: [PATCH 07/23] configure ruby_llm for gemini in an initializer --- config/initializers/ruby_llm_gemini.rb | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 config/initializers/ruby_llm_gemini.rb diff --git a/config/initializers/ruby_llm_gemini.rb b/config/initializers/ruby_llm_gemini.rb new file mode 100644 index 0000000..5b7a30b --- /dev/null +++ b/config/initializers/ruby_llm_gemini.rb @@ -0,0 +1,3 @@ +RubyLLM.configure do |config| + config.gemini_api_key = ENV["GEMINI_API_KEY"] +end From 2ea78e4063dd0b1976a92ad8419b5978024396d5 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 18:49:28 +0500 Subject: [PATCH 08/23] add supportchat to build a groq chat in one place --- app/agents/support_chat.rb | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/agents/support_chat.rb diff --git a/app/agents/support_chat.rb b/app/agents/support_chat.rb new file mode 100644 index 0000000..8ccf15b --- /dev/null +++ b/app/agents/support_chat.rb @@ -0,0 +1,7 @@ +class SupportChat + # Groq speaks only the Chat Completions protocol, and its models are not in + # RubyLLM's registry, so every chat needs these three options. + def self.build + RubyLLM.chat(provider: :openai, protocol: :chat_completions, assume_model_exists: true) + end +end From 52c44b7f4846797dc93eee31da9feead80e99741 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 18:52:16 +0500 Subject: [PATCH 09/23] move chat completions protocol into the groq initializer --- app/agents/support_chat.rb | 6 +++--- config/initializers/ruby_llm_groq.rb | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/agents/support_chat.rb b/app/agents/support_chat.rb index 8ccf15b..8da3925 100644 --- a/app/agents/support_chat.rb +++ b/app/agents/support_chat.rb @@ -1,7 +1,7 @@ class SupportChat - # Groq speaks only the Chat Completions protocol, and its models are not in - # RubyLLM's registry, so every chat needs these three options. + # Groq's models are not in RubyLLM's registry, so every chat needs + # assume_model_exists. The Chat Completions protocol is set in the Groq initializer. def self.build - RubyLLM.chat(provider: :openai, protocol: :chat_completions, assume_model_exists: true) + RubyLLM.chat(provider: :openai, assume_model_exists: true) end end diff --git a/config/initializers/ruby_llm_groq.rb b/config/initializers/ruby_llm_groq.rb index 980eea2..56166c8 100644 --- a/config/initializers/ruby_llm_groq.rb +++ b/config/initializers/ruby_llm_groq.rb @@ -1,5 +1,6 @@ RubyLLM.configure do |config| config.openai_api_key = ENV["GROQ_API_KEY"] config.openai_api_base = "https://api.groq.com/openai/v1" + config.openai_protocol = :chat_completions config.default_model = "openai/gpt-oss-120b" end From 8ddf5b95ca0667dceb0ec51196b765199dd9a6d8 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 18:54:25 +0500 Subject: [PATCH 10/23] update groq note to use the openai_protocol setting --- docs/PROJECT_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 71215a5..1ba24f5 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -255,7 +255,7 @@ The project choice is locked. New project plans from other tools are not conside - [ ] Olist as the base data, or fully generated data only - [ ] Licenses of the Twitter support dataset checked (Olist done: CC BY-NC-SA 4.0; Bitext done: CDLA-Sharing-1.0, text at https://cdla.dev/sharing-1-0/, use and results are unrestricted, publishing the data itself requires the same license and credit) - [ ] Groq free-tier limits checked in the Groq console -- [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Verified in the console on 2026-10-09 with ruby_llm 2.1.0: also pass `protocol: :chat_completions`, because 2.x defaults OpenAI to the Responses API and Groq rejects its `include` field. `llama-3.3-70b-versatile` is gone from Groq; `openai/gpt-oss-120b` answers. Whether a given Groq model supports structured output is tested in the console in lesson 2 +- [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Verified in the console on 2026-10-09 with ruby_llm 2.1.0: also set `config.openai_protocol = :chat_completions`, because 2.x defaults OpenAI to the Responses API and Groq rejects its `include` field. `llama-3.3-70b-versatile` is gone from Groq; `openai/gpt-oss-120b` answers. Whether a given Groq model supports structured output is tested in the console in lesson 2 - [ ] RubyLLM support for Ollama embeddings confirmed - [ ] Blog platform chosen (DEV.to suggested) From 0300da8f052867bf77afd5148a5c7a4304f4148a Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 19:03:40 +0500 Subject: [PATCH 11/23] replace supportchat with a ruby_llm agent class --- app/agents/support_agent.rb | 5 +++++ app/agents/support_chat.rb | 7 ------- 2 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 app/agents/support_agent.rb delete mode 100644 app/agents/support_chat.rb diff --git a/app/agents/support_agent.rb b/app/agents/support_agent.rb new file mode 100644 index 0000000..bdd7e3d --- /dev/null +++ b/app/agents/support_agent.rb @@ -0,0 +1,5 @@ +class SupportAgent < RubyLLM::Agent + # Groq's models are not in RubyLLM's registry, so assume_model_exists is needed. + # The Chat Completions protocol is set in the Groq initializer. + model "openai/gpt-oss-120b", provider: :openai, assume_model_exists: true +end diff --git a/app/agents/support_chat.rb b/app/agents/support_chat.rb deleted file mode 100644 index 8da3925..0000000 --- a/app/agents/support_chat.rb +++ /dev/null @@ -1,7 +0,0 @@ -class SupportChat - # Groq's models are not in RubyLLM's registry, so every chat needs - # assume_model_exists. The Chat Completions protocol is set in the Groq initializer. - def self.build - RubyLLM.chat(provider: :openai, assume_model_exists: true) - end -end From f7b01e87951b65edca0da5685acd7ccdb370e7bb Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 19:06:10 +0500 Subject: [PATCH 12/23] add support agent system prompt --- app/prompts/support_agent/instructions.txt.erb | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 app/prompts/support_agent/instructions.txt.erb diff --git a/app/prompts/support_agent/instructions.txt.erb b/app/prompts/support_agent/instructions.txt.erb new file mode 100644 index 0000000..872317f --- /dev/null +++ b/app/prompts/support_agent/instructions.txt.erb @@ -0,0 +1,6 @@ +You are a customer support agent for an online store. + +Be concise and polite. Answer in plain language, in a few sentences. +You do not have access to order data yet. If a customer asks about a specific +order, say you cannot look it up and do not guess. Never invent order numbers, +delivery dates or policies. From bc8239858ce4dcae7979f933ce297eb17af5b9c4 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 19:14:44 +0500 Subject: [PATCH 13/23] add vcr and webmock for recording llm responses in tests --- Gemfile | 2 ++ Gemfile.lock | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Gemfile b/Gemfile index abb4ad9..7ad261d 100644 --- a/Gemfile +++ b/Gemfile @@ -71,6 +71,8 @@ group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] gem "capybara" gem "selenium-webdriver" + gem "vcr" + gem "webmock" end gem "csv" diff --git a/Gemfile.lock b/Gemfile.lock index 4d868d0..78efdf1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,6 +101,9 @@ GEM xpath (~> 3.2) concurrent-ruby (1.3.8) connection_pool (3.0.2) + crack (1.0.1) + bigdecimal + rexml crass (1.0.7) csv (3.3.6) date (3.5.1) @@ -146,6 +149,7 @@ GEM raabro (~> 1.4) globalid (1.4.0) activesupport (>= 6.1) + hashdiff (1.2.1) i18n (1.15.2) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -303,6 +307,7 @@ GEM regexp_parser (2.13.1) reline (0.7.0) io-console (~> 0.5) + rexml (3.4.4) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -419,10 +424,15 @@ GEM unicode-emoji (4.3.0) uri (1.1.1) useragent (0.16.11) + vcr (6.4.0) web-console (4.3.0) actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + webmock (3.26.4) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) websocket-driver (0.8.2) base64 @@ -472,7 +482,9 @@ DEPENDENCIES thruster turbo-rails tzinfo-data + vcr web-console + webmock CHECKSUMS action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 @@ -500,6 +512,7 @@ CHECKSUMS capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 @@ -527,6 +540,7 @@ CHECKSUMS ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e fugit (1.14.0) sha256=e311a9cc85a54bb67649df45060e721282a1e2c8ebad40e41874a3a7d576f37e globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a @@ -592,6 +606,7 @@ CHECKSUMS rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a regexp_parser (2.13.1) sha256=5aedb6b7c35688f51e86eef17a15374dfd287c83f4ca644b2c5f3ec50a33b44b reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 @@ -632,7 +647,9 @@ CHECKSUMS unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + vcr (6.4.0) sha256=077ac92cc16efc5904eb90492a18153b5e6ca5398046d8a249a7c96a9ea24ae6 web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + webmock (3.26.4) sha256=8d8da206d217ebe6968cfb09c77f4533c23074e1432bad865f3994eacbaad50d websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 From 07266cf380324533ce6952acc38f34af0a1a4728 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 19:22:03 +0500 Subject: [PATCH 14/23] configure vcr to record llm calls and hide the groq key --- spec/rails_helper.rb | 2 +- spec/support/vcr.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 spec/support/vcr.rb diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index bfcbc18..ef4fb31 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -23,7 +23,7 @@ # directory. Alternatively, in the individual `*_spec.rb` files, manually # require only the support files necessary. # -# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } +Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } # Ensures that the test database schema matches the current schema file. # If there are pending migrations it will invoke `db:test:prepare` to diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb new file mode 100644 index 0000000..ab56cb4 --- /dev/null +++ b/spec/support/vcr.rb @@ -0,0 +1,10 @@ +require "vcr" + +VCR.configure do |config| + config.cassette_library_dir = "spec/cassettes" + config.hook_into :webmock + + # The key travels in the Authorization header, so without this filter it would + # be written into the cassette and committed. + config.filter_sensitive_data("") { ENV["GROQ_API_KEY"] } +end From 6b3d95fa1d05c29f8a3c31e816348ad4d5688318 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 19:23:06 +0500 Subject: [PATCH 15/23] match vcr cassettes on request body --- spec/support/vcr.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb index ab56cb4..4b818e2 100644 --- a/spec/support/vcr.rb +++ b/spec/support/vcr.rb @@ -4,6 +4,10 @@ config.cassette_library_dir = "spec/cassettes" config.hook_into :webmock + # Include the body so a changed prompt or question forces a re-record instead + # of silently replaying an old answer. + config.default_cassette_options = { match_requests_on: %i[method uri body] } + # The key travels in the Authorization header, so without this filter it would # be written into the cassette and committed. config.filter_sensitive_data("") { ENV["GROQ_API_KEY"] } From fadef8519a34efe5bb7af883f2aad32fc712d69d Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 19:23:21 +0500 Subject: [PATCH 16/23] add lesson 2 doc and link it --- README.md | 1 + docs/README.md | 1 + docs/lessons/02-first-llm-call.md | 56 +++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 docs/lessons/02-first-llm-call.md diff --git a/README.md b/README.md index 6bd476b..e065d0f 100644 --- a/README.md +++ b/README.md @@ -190,5 +190,6 @@ This is a learning project about building a reliable AI agent, not a complete su - The full plan, with architecture, gems by lesson, data mapping and the evaluation plan: [docs/PROJECT_PLAN.md](docs/PROJECT_PLAN.md) - The database tables and how they relate, as a diagram: [docs/DATA_MODEL.md](docs/DATA_MODEL.md) - Lesson progress, decisions and what broke: [docs/lessons/01-data-model-and-import.md](docs/lessons/01-data-model-and-import.md) +- Lesson 2, the first LLM call: [docs/lessons/02-first-llm-call.md](docs/lessons/02-first-llm-call.md) - Index of all documentation: [docs/README.md](docs/README.md) - Where the code lives: this GitHub repo is the source of truth, and pull requests and issues are here. A read-only mirror is kept on [GitLab](https://gitlab.com/mjesar/support_engineer). diff --git a/docs/README.md b/docs/README.md index ba02767..f4c71ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ lesson pages in order. | [PROJECT_PLAN.md](PROJECT_PLAN.md) | The whole project: scenario, architecture, stack, tools and permissions, 14 lessons, evaluation plan. | | [DATA_MODEL.md](DATA_MODEL.md) | The database as a diagram, why each table looks the way it does, and where its data comes from. | | [lessons/01-data-model-and-import.md](lessons/01-data-model-and-import.md) | Lesson 1: progress checklist, decisions, what broke and measured results. | +| [lessons/02-first-llm-call.md](lessons/02-first-llm-call.md) | Lesson 2: Groq through RubyLLM 2.x, the agent and its prompt, testing with recorded calls. | ## Conventions diff --git a/docs/lessons/02-first-llm-call.md b/docs/lessons/02-first-llm-call.md new file mode 100644 index 0000000..60835de --- /dev/null +++ b/docs/lessons/02-first-llm-call.md @@ -0,0 +1,56 @@ +# Lesson 2: the first LLM call + +The app talks to a model for the first time: one agent class, a system prompt in its own file, and a +test that does not call the API on every run. + +Last updated: 2026-10-09 + +## Status + +- [x] `ruby_llm` 2.1.0 added, Groq and Gemini configured in initializers +- [x] `SupportAgent` (a `RubyLLM::Agent`) answers through Groq (`openai/gpt-oss-120b`) +- [x] System prompt as a template file +- [x] VCR and WebMock set up, with the Groq key filtered out of recordings +- [ ] First agent spec with a recorded cassette +- [ ] Structured output tested in the console on the chosen Groq model +- [ ] Tag `lesson-02` on the merge commit of the lesson 2 pull request + +## What was built + +``` +config/initializers/ruby_llm_groq.rb Groq key, URL and protocol (through the OpenAI settings) +config/initializers/ruby_llm_gemini.rb Gemini key +app/agents/support_agent.rb the agent: which model, nothing else +app/prompts/support_agent/instructions.txt.erb the system prompt +spec/support/vcr.rb records LLM calls once, replays them after +``` + +The agent finds its prompt by name: class `SupportAgent` reads `app/prompts/support_agent/instructions.txt.erb`. +Rename one without the other and the prompt silently stops loading. + +## Groq through RubyLLM 2.x + +RubyLLM has no Groq provider, so Groq is reached through its OpenAI-compatible API. Three things were +needed, and each one failed first: + +- `openai_api_base` points the OpenAI settings at Groq's URL. +- `openai_protocol = :chat_completions`, because 2.x defaults OpenAI to the newer Responses API and Groq + rejects its `include` field (`Field 'include' is not supported`). +- `assume_model_exists: true`, because Groq's model names are not in RubyLLM's registry. + +There is only one set of OpenAI settings globally, so real OpenAI could not be used next to Groq without +a separate `RubyLLM.context`. That is not needed yet. + +Models retire: `llama-3.3-70b-versatile` is gone from Groq and `gemini-2.5-flash` is closed to new users. +List what a key can use with a call to the provider's `/models` endpoint before picking a model. + +## Testing an LLM call + +WebMock blocks real HTTP in specs. VCR lets one real call through, saves it as a cassette in +`spec/cassettes/`, and replays it on later runs, so specs are fast, free and repeatable. + +- The Groq key is replaced with `` before anything is saved, so cassettes are safe to commit. +- Requests are matched on method, URL and body, so changing the prompt or the question forces a new + recording instead of replaying an old answer. +- A cassette tests the wiring (config, prompt loading, parsing the reply), not whether the answer is good. + Answer quality is measured with evals in lesson 10. Keep the cassette specs few. From 3fcec2ad6d708914abaca58d7151fec5b47de660 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 19:27:22 +0500 Subject: [PATCH 17/23] add first support agent spec with a recorded groq call --- spec/agents/support_agent_spec.rb | 9 +++ spec/cassettes/support_agent/replies.yml | 83 ++++++++++++++++++++++++ spec/support/vcr.rb | 7 +- 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 spec/agents/support_agent_spec.rb create mode 100644 spec/cassettes/support_agent/replies.yml diff --git a/spec/agents/support_agent_spec.rb b/spec/agents/support_agent_spec.rb new file mode 100644 index 0000000..73b4266 --- /dev/null +++ b/spec/agents/support_agent_spec.rb @@ -0,0 +1,9 @@ +require "rails_helper" + +RSpec.describe SupportAgent do + it "replies with text", vcr: { cassette_name: "support_agent/replies" } do + reply = described_class.new.ask("Say hi") + + expect(reply.content).to be_present + end +end diff --git a/spec/cassettes/support_agent/replies.yml b/spec/cassettes/support_agent/replies.yml new file mode 100644 index 0000000..243db19 --- /dev/null +++ b/spec/cassettes/support_agent/replies.yml @@ -0,0 +1,83 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.groq.com/openai/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"openai/gpt-oss-120b","messages":[{"role":"developer","content":"You + are a customer support agent for an online store.\n\nBe concise and polite. + Answer in plain language, in a few sentences.\nYou do not have access to order + data yet. If a customer asks about a specific\norder, say you cannot look + it up and do not guess. Never invent order numbers,\ndelivery dates or policies.\n"},{"role":"user","content":"Say + hi"}],"stream":false}' + headers: + User-Agent: + - Faraday v2.14.4 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 09 Oct 2026 14:25:56 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - private, max-age=0, no-store, no-cache, must-revalidate + Server: + - cloudflare + Vary: + - Origin + X-Groq-Region: + - bom + X-Ratelimit-Limit-Requests: + - '1000' + X-Ratelimit-Limit-Tokens: + - '8000' + X-Ratelimit-Remaining-Requests: + - '999' + X-Ratelimit-Remaining-Tokens: + - '7487' + X-Ratelimit-Reset-Requests: + - 1m26.4s + X-Ratelimit-Reset-Tokens: + - 3.847s + X-Request-Id: + - req_01m4ggwvdzegpsafgc7w4haah7 + Set-Cookie: + - __cf_bm=VvvwpQdMw7CuaPzSm49ozb.jaEYYci.dFpq5.18AW8w-1791555956.115957-1.0.1.1-1ruD1ufI4vAVeSHWzg._eOTpjgqXhp2as4j2aIpqCUz7bjsNziXUFZn6BOuMj5KW3IVqYUn3TE9d554iqef4DpSs3Zxm1pdG17Ud34G.TGLudF_NdOc1ABgNciG5znjs; + HttpOnly; SameSite=None; Secure; Path=/; Domain=groq.com; Expires=Fri, 09 + Oct 2026 14:55:56 GMT + Via: + - 1.1 google + Strict-Transport-Security: + - max-age=15552000 + Cf-Cache-Status: + - DYNAMIC + Cf-Ray: + - a47e0eb5b9e8ff96-SIN + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"id":"chatcmpl-90435c37-f467-4151-85ef-53ceb933913f","object":"chat.completion","created":1791555956,"model":"openai/gpt-oss-120b","choices":[{"index":0,"message":{"role":"assistant","content":"Hello! + How can I assist you today?","reasoning":"The user says \"Say hi\". The system + wants a concise polite response, maybe \"Hello! How can I help you today?\" + As a customer support agent. Should be short."},"logprobs":null,"finish_reason":"stop"}],"usage":{"queue_time":0.21210401,"prompt_tokens":143,"prompt_time":0.006743515,"completion_tokens":55,"completion_time":0.118240139,"total_tokens":198,"total_time":0.124983654,"completion_tokens_details":{"reasoning_tokens":37}},"usage_breakdown":null,"system_fingerprint":"fp_86dc843b12","x_groq":{"id":"req_01m4ggwvdzegpsafgc7w4haah7","seed":968393856},"service_tier":"on_demand"} + + ' + recorded_at: Fri, 09 Oct 2026 14:25:56 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb index 4b818e2..76cbab6 100644 --- a/spec/support/vcr.rb +++ b/spec/support/vcr.rb @@ -3,6 +3,7 @@ VCR.configure do |config| config.cassette_library_dir = "spec/cassettes" config.hook_into :webmock + config.configure_rspec_metadata! # Include the body so a changed prompt or question forces a re-record instead # of silently replaying an old answer. @@ -10,5 +11,9 @@ # The key travels in the Authorization header, so without this filter it would # be written into the cassette and committed. - config.filter_sensitive_data("") { ENV["GROQ_API_KEY"] } + config.filter_sensitive_data("") { RubyLLM.config.openai_api_key } end + +# RubyLLM refuses to build a chat without a key, even when VCR only replays. +# This lets anyone run the specs from the cassettes without a Groq account. +RubyLLM.config.openai_api_key ||= "replay-only-key" From 82a0e8621b7846b5c6d8839700305f96ede7f6cc Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 21:23:52 +0500 Subject: [PATCH 18/23] record structured output result and testing lessons in lesson 2 --- docs/PROJECT_PLAN.md | 2 +- docs/lessons/02-first-llm-call.md | 30 ++++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 1ba24f5..aa22535 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -255,7 +255,7 @@ The project choice is locked. New project plans from other tools are not conside - [ ] Olist as the base data, or fully generated data only - [ ] Licenses of the Twitter support dataset checked (Olist done: CC BY-NC-SA 4.0; Bitext done: CDLA-Sharing-1.0, text at https://cdla.dev/sharing-1-0/, use and results are unrestricted, publishing the data itself requires the same license and credit) - [ ] Groq free-tier limits checked in the Groq console -- [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Verified in the console on 2026-10-09 with ruby_llm 2.1.0: also set `config.openai_protocol = :chat_completions`, because 2.x defaults OpenAI to the Responses API and Groq rejects its `include` field. `llama-3.3-70b-versatile` is gone from Groq; `openai/gpt-oss-120b` answers. Whether a given Groq model supports structured output is tested in the console in lesson 2 +- [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Verified in the console on 2026-10-09 with ruby_llm 2.1.0: also set `config.openai_protocol = :chat_completions`, because 2.x defaults OpenAI to the Responses API and Groq rejects its `include` field. `llama-3.3-70b-versatile` is gone from Groq; `openai/gpt-oss-120b` answers. Structured output verified in the console on 2026-10-09: `openai/gpt-oss-120b` returns a hash that matches a Schematist schema through `chat.with_schema(...)` and `reply.parsed` - [ ] RubyLLM support for Ollama embeddings confirmed - [ ] Blog platform chosen (DEV.to suggested) diff --git a/docs/lessons/02-first-llm-call.md b/docs/lessons/02-first-llm-call.md index 60835de..ae33793 100644 --- a/docs/lessons/02-first-llm-call.md +++ b/docs/lessons/02-first-llm-call.md @@ -11,8 +11,8 @@ Last updated: 2026-10-09 - [x] `SupportAgent` (a `RubyLLM::Agent`) answers through Groq (`openai/gpt-oss-120b`) - [x] System prompt as a template file - [x] VCR and WebMock set up, with the Groq key filtered out of recordings -- [ ] First agent spec with a recorded cassette -- [ ] Structured output tested in the console on the chosen Groq model +- [x] First agent spec with a recorded cassette +- [x] Structured output tested in the console on the chosen Groq model - [ ] Tag `lesson-02` on the merge commit of the lesson 2 pull request ## What was built @@ -44,12 +44,38 @@ a separate `RubyLLM.context`. That is not needed yet. Models retire: `llama-3.3-70b-versatile` is gone from Groq and `gemini-2.5-flash` is closed to new users. List what a key can use with a call to the provider's `/models` endpoint before picking a model. +## Structured output + +Tried in the console on `openai/gpt-oss-120b`, with a schema class and one question: + +```ruby +class Triage < Schematist::Schema + string :intent, enum: %w[order_status refund complaint other] + string :summary +end + +reply = SupportAgent.new.chat.with_schema(Triage).ask("Where is my package? It was due last week.") +reply.parsed # => {"intent" => "order_status", "summary" => "User is inquiring about ..."} +``` + +The reply is a hash that matches the schema, so the router in a later lesson can rely on a clean intent +instead of parsing free text. This was one question, not a measured result: how often the model picks the +right intent is a job for evals. + +## Reading a reply in 2.x + +`reply.content` is the text, `reply.model` the model, `reply.tokens.input` and `reply.tokens.output` the +usage, and `reply.finish_reason` why it stopped. These names changed from 1.x (`input_tokens`, `model_id`). + ## Testing an LLM call WebMock blocks real HTTP in specs. VCR lets one real call through, saves it as a cassette in `spec/cassettes/`, and replays it on later runs, so specs are fast, free and repeatable. - The Groq key is replaced with `` before anything is saved, so cassettes are safe to commit. +- RubyLLM refuses to build a chat without a key, even when VCR only replays, so `spec/support/vcr.rb` + sets a throwaway key when none is present. Anyone can run the specs from the cassettes without a Groq account. +- VCR ignores the `vcr:` tag on an example unless `configure_rspec_metadata!` is set. - Requests are matched on method, URL and body, so changing the prompt or the question forces a new recording instead of replaying an old answer. - A cassette tests the wiring (config, prompt loading, parsing the reply), not whether the answer is good. From 3bbb86ce2e4ff8b3a59accdbf364853a65da5d4f Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 21:30:36 +0500 Subject: [PATCH 19/23] record structured output result and testing lessons in lesson 2 --- docs/lessons/02-first-llm-call.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/lessons/02-first-llm-call.md b/docs/lessons/02-first-llm-call.md index ae33793..b664664 100644 --- a/docs/lessons/02-first-llm-call.md +++ b/docs/lessons/02-first-llm-call.md @@ -58,6 +58,9 @@ reply = SupportAgent.new.chat.with_schema(Triage).ask("Where is my package? It w reply.parsed # => {"intent" => "order_status", "summary" => "User is inquiring about ..."} ``` +`reply.parsed` only works on a reply that came from a schema. On a plain reply it raises +`JSON::ParserError`, because it tries to read the text as JSON. + The reply is a hash that matches the schema, so the router in a later lesson can rely on a clean intent instead of parsing free text. This was one question, not a measured result: how often the model picks the right intent is a job for evals. From b7a86816a386fd545606acfe47100f3ca0dd91e1 Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 21:33:33 +0500 Subject: [PATCH 20/23] record structured output result and testing lessons in lesson 2 --- app/agents/triage.rb | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 app/agents/triage.rb diff --git a/app/agents/triage.rb b/app/agents/triage.rb new file mode 100644 index 0000000..1f129cd --- /dev/null +++ b/app/agents/triage.rb @@ -0,0 +1,4 @@ +class Triage < Schematist::Schema + string :intent, enum: %w[order_status refund complaint other] + string :summary +end From 14f5d25431a69e1cdc0fd47b2977e7ebeb3d3bdb Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 21:34:15 +0500 Subject: [PATCH 21/23] add next step lookup that turns an intent into an action --- app/agents/next_step.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/agents/next_step.rb diff --git a/app/agents/next_step.rb b/app/agents/next_step.rb new file mode 100644 index 0000000..727b43b --- /dev/null +++ b/app/agents/next_step.rb @@ -0,0 +1,14 @@ +class NextStep + STEPS = { + "order_status" => :look_up_order, + "refund" => :check_refund_policy, + "complaint" => :escalate_to_human, + "other" => :answer_directly + }.freeze + + # fetch raises on an unknown intent: the schema only allows these four, so + # anything else is a bug to see, not a case to guess a default for. + def self.for(intent) + STEPS.fetch(intent) + end +end From cb3546dd3939ffaae7e5c369b5b3dab0e7eb6dab Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 21:35:59 +0500 Subject: [PATCH 22/23] add specs for next step, triage schema and the agent prompt --- spec/agents/next_step_spec.rb | 25 +++++++++++++++++++++++++ spec/agents/support_agent_spec.rb | 7 +++++++ spec/agents/triage_spec.rb | 11 +++++++++++ 3 files changed, 43 insertions(+) create mode 100644 spec/agents/next_step_spec.rb create mode 100644 spec/agents/triage_spec.rb diff --git a/spec/agents/next_step_spec.rb b/spec/agents/next_step_spec.rb new file mode 100644 index 0000000..de01594 --- /dev/null +++ b/spec/agents/next_step_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" + +RSpec.describe NextStep do + describe ".for" do + it "looks up an order for order_status" do + expect(described_class.for("order_status")).to eq(:look_up_order) + end + + it "checks the refund policy for refund" do + expect(described_class.for("refund")).to eq(:check_refund_policy) + end + + it "escalates a complaint to a human" do + expect(described_class.for("complaint")).to eq(:escalate_to_human) + end + + it "answers directly for other" do + expect(described_class.for("other")).to eq(:answer_directly) + end + + it "raises on an intent the schema does not allow" do + expect { described_class.for("banana") }.to raise_error(KeyError) + end + end +end diff --git a/spec/agents/support_agent_spec.rb b/spec/agents/support_agent_spec.rb index 73b4266..75e70d7 100644 --- a/spec/agents/support_agent_spec.rb +++ b/spec/agents/support_agent_spec.rb @@ -1,6 +1,13 @@ require "rails_helper" RSpec.describe SupportAgent do + # The prompt is found by name, so a renamed folder fails silently without this. + it "loads its system prompt from app/prompts/support_agent" do + system_prompt = described_class.new.chat.messages.first + + expect(system_prompt.content).to include("customer support agent") + end + it "replies with text", vcr: { cassette_name: "support_agent/replies" } do reply = described_class.new.ask("Say hi") diff --git a/spec/agents/triage_spec.rb b/spec/agents/triage_spec.rb new file mode 100644 index 0000000..e947ef0 --- /dev/null +++ b/spec/agents/triage_spec.rb @@ -0,0 +1,11 @@ +require "rails_helper" + +RSpec.describe Triage do + # The schema and NextStep are two files that must agree. If an intent is added + # to one and not the other, the model could return a value NextStep cannot route. + it "allows exactly the intents NextStep can route" do + intents = described_class.new.to_json_schema.dig("properties", "intent", "enum") + + expect(intents).to match_array(NextStep::STEPS.keys) + end +end From ccd28c49e9a90ebc3bf986c2f974aca6fb222ccb Mon Sep 17 00:00:00 2001 From: mjesar Date: Fri, 9 Oct 2026 21:37:38 +0500 Subject: [PATCH 23/23] record groq free tier limits in the plan --- docs/PROJECT_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index aa22535..b03e178 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -254,7 +254,7 @@ The project choice is locked. New project plans from other tools are not conside - [ ] Solid Queue + Solid Cache, or Sidekiq + Redis - [ ] Olist as the base data, or fully generated data only - [ ] Licenses of the Twitter support dataset checked (Olist done: CC BY-NC-SA 4.0; Bitext done: CDLA-Sharing-1.0, text at https://cdla.dev/sharing-1-0/, use and results are unrestricted, publishing the data itself requires the same license and credit) -- [ ] Groq free-tier limits checked in the Groq console +- [x] Groq free-tier limits checked (2026-10-09, read from the response headers for `openai/gpt-oss-120b`, not from the console): 1000 requests (the reset time suggests per day) and 8000 tokens per minute. The token limit is the tight one: long prompts, tool results and RAG context in later lessons will hit it, and so will eval runs with many cases - [x] RubyLLM support for Groq confirmed (2026-10-08): no native provider, use the OpenAI-compatible route (`openai_api_base` set to Groq's OpenAI endpoint, `provider: :openai`, `assume_model_exists: true`, per the RubyLLM docs). Verified in the console on 2026-10-09 with ruby_llm 2.1.0: also set `config.openai_protocol = :chat_completions`, because 2.x defaults OpenAI to the Responses API and Groq rejects its `include` field. `llama-3.3-70b-versatile` is gone from Groq; `openai/gpt-oss-120b` answers. Structured output verified in the console on 2026-10-09: `openai/gpt-oss-120b` returns a hash that matches a Schematist schema through `chat.with_schema(...)` and `reply.parsed` - [ ] RubyLLM support for Ollama embeddings confirmed - [ ] Blog platform chosen (DEV.to suggested)