<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>AI - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/ai/</link><description>AI - Tag - Tracy Atteberry</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>tracy@magicbydesign.com (Tracy Atteberry)</managingEditor><webMaster>tracy@magicbydesign.com (Tracy Atteberry)</webMaster><lastBuildDate>Wed, 22 Jul 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>AI - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/ai/</link></image><atom:link href="https://tracyatteberry.com/tags/ai/" rel="self" type="application/rss+xml"/><item><title>Impeccable</title><link>https://tracyatteberry.com/notes/impeccable/</link><pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/notes/impeccable/</guid><description><![CDATA[<p>Does your frontend AI skill give you a site that looks like all the other AI
sites? That&rsquo;s probably because if you don&rsquo;t give some guidance otherwise, you
tend to get the same font (Inter), same gradients, same nest of cards, etc. The
AI design slop. So when I needed to re-create a few simple single-page sites
recently, I reached for <a href="https://github.com/pbakaus/impeccable" target="_blank" rel="noopener noreffer ">Impeccable</a>.
It&rsquo;s a very nice AI skill to help <code>craft</code>, <code>critique</code>, <code>optimize</code> (and 21 other
commands) your frontend design. I used it to re-create
<a href="https://magicbydesign.com" target="_blank" rel="noopener noreffer ">MagicByDesign.com</a> (an old site with defunct
content, but still my primary email domain) and
<a href="https://atteberryshandcrafted.com" target="_blank" rel="noopener noreffer ">AtteberrysHandcrafted.com</a> (for my brother,
still a definite work in progress, but I thought it did a nice job with the
assets I had at the time).</p>
<p>You may not care for those site designs, but that&rsquo;s not the point. The point is
that they don&rsquo;t look like so many other generated sites and impeccable <em>did</em>
achieve the looks I was after.</p>
<p>From <a href="https://impeccable.style/" target="_blank" rel="noopener noreffer ">Impeccable.stlye</a>:</p>
<blockquote>
<p>Impeccable strips the slop from AI-generated interfaces, gives you precise
commands to steer, and iterates visual variants live in your product. Then
the dice: pushed out of distribution, your model designs ambitious visual
worlds it could never reach alone.</p>
</blockquote>
<p>Recommended.</p>
]]></description></item><item><title>TestGenAI</title><link>https://tracyatteberry.com/portfolio/testgenai/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/testgenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/testgenai/hero.jpg" referrerpolicy="no-referrer">
            </div><p>Building a presentation thingy.</p>
]]></description></item><item><title>TestGenAI: Building a Ruby CLI that writes your missing tests</title><link>https://tracyatteberry.com/posts/testgenai/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/testgenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/testgenai/hero.jpg" referrerpolicy="no-referrer">
            </div><h1 id="creating-a-gem-that-writes-your-missing-tests">Creating a gem that writes your missing tests</h1>
<p>No new project survives contact with the real world unscathed. We built
TestGenAI, ran it on itself, and it worked well. Then we ran it on another
codebase, and two things broke immediately. The fixes turned out to just as
interesting as the original build.</p>
<p>This is a walkthrough of how the tool works and what we learned when we took it
outside the greenhouse.</p>
<p>The code here is from <a href="https://github.com/grymoire7/testgenai" target="_blank" rel="noopener noreffer ">TestGenAI</a>, a
working Ruby CLI gem you can install and run against your own codebase.</p>
<h2 id="the-pipeline">The pipeline</h2>
<p>The pipeline has five stages:</p>
<ol>
<li>Scan your codebase to find classes and methods without test coverage</li>
<li>Build context for each untested method</li>
<li>Generate tests using an LLM with the mechanically curated context</li>
<li>Validate that the generated tests run and pass</li>
<li>Collect the results</li>
</ol>
<p>Each stage needs to be reliable enough that you can walk away and trust the
process to complete. That means handling errors gracefully, providing clear
output about what happened, and making it easy to pick up where things left off
if something breaks.</p>
<h2 id="finding-untested-code">Finding untested code</h2>
<p>Before you can generate tests, you need to know what needs testing. The right
approach depends on whether SimpleCov is available in the project.</p>
<p>If SimpleCov is set up, TestGenAI runs your test suite with <code>COVERAGE=true</code>,
reads the resulting <code>coverage/.resultset.json</code>, and uses AST parsing to find
methods where every executable line has zero hits. This scanner handles
partially-tested files correctly. It reports individual methods that were
never exercised, even if other methods in the same file have full coverage.</p>
<p>If SimpleCov isn&rsquo;t available, the scanner falls back to checking whether a spec
or test file exists for each source file. This approach is less accurate. A
file tested only through integration tests or through specs for its subclasses
will appear fully untested even if its methods are exercised constantly. The
SimpleCov scanner is worth setting up.</p>
<p>Both scanners share the same underlying logic for locating methods in source
files, which brings up something worth explaining.</p>
<h2 id="walking-the-ast">Walking the AST</h2>
<p>To locate methods, TestGenAI parses each Ruby source file into an abstract
syntax tree and walks it recursively. The walker looks for <code>:def</code> and <code>:defs</code>
nodes (instance and class methods), tracks the current class/module namespace,
and records each method&rsquo;s file, class, name, and line range.</p>
<p>That line range matters. The SimpleCov scanner uses it to check whether any
executable lines in the method had zero hits. A <code>nil</code> in SimpleCov&rsquo;s coverage
array means a line isn&rsquo;t executable, like a blank line, a comment, or an <code>end</code>.
The scanner filters those out before checking for zeros, so it only flags
methods where runnable code was never touched.</p>
<h3 id="the-parser-compatibility-problem">The parser compatibility problem</h3>
<p>To parse Ruby, the gem relies on the <code>parser</code> gem. In older versions, you&rsquo;d
call <code>Parser::CurrentRuby.parse(source)</code> and get back an AST. This worked fine
until Ruby 3.4, which switched its internal default parser to prism. Using
<code>Parser::CurrentRuby</code> with Ruby 3.4 produces warnings, and in some
configurations it fails entirely.</p>
<p>The prism project ships a compatibility shim,
<code>Prism::Translation::ParserCurrent</code>, that produces the same AST node types as
the old parser gem. The AST-walking code works unchanged. The only question is
which one to load.</p>
<p>The solution is a small file that runs at load time and sets a constant:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Testgenai</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">Gem</span><span class="o">::</span><span class="no">Version</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="no">RUBY_VERSION</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="no">Gem</span><span class="o">::</span><span class="no">Version</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="s2">&#34;3.4&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">require</span> <span class="s2">&#34;prism&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">CurrentParser</span> <span class="o">=</span> <span class="no">Prism</span><span class="o">::</span><span class="no">Translation</span><span class="o">::</span><span class="no">ParserCurrent</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">require</span> <span class="s2">&#34;parser/current&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">CurrentParser</span> <span class="o">=</span> <span class="no">Parser</span><span class="o">::</span><span class="no">CurrentRuby</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The rest of the codebase calls <code>CurrentParser.parse(source)</code> and never thinks
about which parser is underneath. The pattern of version check at load time,
constant as the abstraction is a clean way to handle the same kind of
compatibility gap you&rsquo;ll run into whenever Ruby ships a significant internal
change.</p>
<h2 id="context-generation-and-validation">Context, generation, and validation</h2>
<p>When you ask an LLM to write tests for a method, you can&rsquo;t just paste in the
method body. It needs the full class, the dependencies that file requires,
examples of how the method is called elsewhere in the codebase, and existing
test files it can match in style. Context quality is where quick-and-dirty AI
test generators fall apart, too little and the tests don&rsquo;t compile, too much
and you hit token limits.</p>
<p>The generator builds a prompt from all of that, sends it to the LLM via the
<code>ruby_llm</code> gem (which keeps the generator code provider-agnostic), and strips
any markdown fences from the response before passing it to the validator.</p>
<p>The validator writes the code to a temp file, runs <code>bundle exec rspec</code> or the
Minitest equivalent, and distinguishes between three outcomes: the file failed
to load (syntax errors, undefined constants), the tests ran but failed, or the
tests passed. Each outcome needs different handling. A file that doesn&rsquo;t load
gets deleted immediately because it&rsquo;s useless. A file that runs but fails gets
its error output fed back to the LLM for a retry.</p>
<p>The pipeline retries up to three times, passing failure details back each time.
LLMs are reasonably good at fixing specific errors when told what went wrong.
Undefined constants and wrong require paths almost always resolve in one retry.
More complex failures, like incorrect behavior assumptions, may not, and those
end up in a failed bucket for manual review.</p>
<h2 id="then-we-ran-it-on-a-real-project">Then we ran it on a real project</h2>
<p>The first external test run revealed two problems, both on the same day.</p>
<p>The first: generated tests were syntactically valid, ran, and passed — but they
looked nothing like the rest of the project&rsquo;s test suite. Wrong authentication
setup, wrong factory usage, helpers that weren&rsquo;t available. Tests that
technically pass but violate project conventions create a maintenance burden.</p>
<p>The second: the tool was silently destroying existing tests. When a spec file
already existed at the output path, the pipeline would overwrite it with the
newly generated content. Any tests already in that file were gone.</p>
<p>Both problems make complete sense in retrospect. The tool had only ever run on
its own codebase, where it was always generating new files and where the
conventions were deeply familiar to the model from the context it was seeing. A
different project broke both assumptions.</p>
<h2 id="fixing-the-conventions-gap">Fixing the conventions gap</h2>
<p>The core problem is that the LLM knows what your method does, but it doesn&rsquo;t
know how your team writes tests. It doesn&rsquo;t know that you authenticate in
<code>before</code> blocks a certain way, or that you have specific factory traits
available, or that you&rsquo;re not using <code>rails-controller-testing</code> so <code>assigns</code>
isn&rsquo;t an option.</p>
<p>The fix is a conventions system with two parts.</p>
<p><code>ConventionsExtractor</code> scans your existing test files and pulls out mechanical
facts: the most common authentication setup pattern, available factory traits
from your factories directory, frequently stubbed objects, whether
transactional fixtures are disabled and how cleanup is handled, and whether
specific helpers are unavailable based on what&rsquo;s in your Gemfile. These aren&rsquo;t
judgments, they&rsquo;re observations extracted directly from the code.</p>
<p><code>ConventionsSynthesizer</code> takes those raw facts and sends them to the LLM with a
prompt asking it to write a concise conventions guide explaining the rule
behind each pattern. The result is plain prose. Something like &ldquo;authentication
is handled in <code>before</code> blocks using <code>session[:user_id] =</code> rather than Devise
helpers; use this pattern consistently.&rdquo; That text gets prepended to every
generation prompt.</p>
<p>The synthesized guide is cached to <code>spec/conventions.md</code> and invalidated
automatically when spec files or Gemfiles change. Regenerating it costs one LLM
call.</p>
<p>Enable it with the <code>--conventions</code> flag:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">testgenai generate --provider anthropic --model claude-opus-4-7 --conventions</span></span></code></pre></div></div>
<p>Add <code>spec/conventions.md</code> to your <code>.gitignore</code>. It&rsquo;s a derived artifact and
probably not something to check in.</p>
<h2 id="fixing-the-overwrite-problem">Fixing the overwrite problem</h2>
<p>The overwrite bug was straightforward to diagnose and subtle to fix correctly.</p>
<p>The naive fix would be to skip generation if a spec file already exists. That&rsquo;s
less than ideal. The point is to add tests for untested methods, and
partially-tested files are the most common case.</p>
<p>Better behavior is to inject the generated tests into the existing file. The
pipeline now reads existing content before doing anything else. It combines
existing content with the newly generated code and validates the combined file.
If it passes, the combined content is written to the spec file.</p>
<p>If it fails, the pipeline restores the original file exactly as it was and
writes the generated-only code to a fallback path. A method in
<code>lib/payments/processor.rb</code> that already has a
<code>spec/payments/processor_spec.rb</code> would get its fallback at
<code>spec/payments/processor_context_spec.rb</code> (using the method name to scope the
filename). The failure output tells you where to find it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><pre tabindex="0"><code>  ✗ Payments::Processor#context failed after 3 attempt(s)
    → Generated tests saved to spec/payments/processor_context_spec.rb for manual review</code></pre></div>
<p>You end up with your original tests intact and the generated attempt sitting
somewhere you can look at it and decide what to do.</p>
<h2 id="running-it">Running it</h2>
<p>Install the gem and point it at your project:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gem install testgenai
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> your_project
</span></span><span class="line"><span class="cl">testgenai generate --provider anthropic --model claude-opus-4-7</span></span></code></pre></div></div>
<p>Or add it to your Gemfile in the development group and use <code>bundle exec</code>.
Configuration can also come from environment variables:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">TESTGENAI_PROVIDER</span><span class="o">=</span>anthropic
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">TESTGENAI_MODEL</span><span class="o">=</span>claude-opus-4-7
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span>your_api_key
</span></span><span class="line"><span class="cl">testgenai generate --conventions</span></span></code></pre></div></div>
<p>Three diagnostic commands are available before you commit to a full run:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">testgenai scan      <span class="c1"># find untested methods without making any API calls</span>
</span></span><span class="line"><span class="cl">testgenai context   <span class="c1"># show what context would be sent to the LLM for each method</span></span></span></code></pre></div></div>
<p><code>scan</code> gives you a picture of your coverage gaps. <code>context</code> is useful for
understanding what the LLM will see before spending API credits.</p>
<p>The goal isn&rsquo;t to replace the developer who understands the code and makes
decisions about testing. It&rsquo;s to handle the mechanical work: setting up
describe blocks, wiring test data, writing happy-path coverage. Then you can
spend your time on the parts that actually need your judgment. The second
project taught us that &ldquo;mechanical&rdquo; is more context-dependent than it looks.</p>
]]></description></item><item><title>MockOpenAI</title><link>https://tracyatteberry.com/portfolio/mockopenai/</link><pubDate>Fri, 20 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/mockopenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mockopenai/mockopenai_hero.jpg" referrerpolicy="no-referrer">
            </div><p>Building a gem.</p>
]]></description></item><item><title>Building MockOpenAI: a weekend MVP story</title><link>https://tracyatteberry.com/posts/mockopenai/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/mockopenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mockopenai/mockopenai_hero.jpg" referrerpolicy="no-referrer">
            </div><h1 id="building-mockopenai-a-weekend-mvp-story">Building MockOpenAI: a weekend MVP story</h1>
<p>Last weekend I built and published a Ruby gem. From idea to published thing
in about four days. Here&rsquo;s how it went, including the part where I had to
reconsider whether I&rsquo;d built something useful at all.</p>
<h2 id="friday-20-ideas-one-bet">Friday: 20 ideas, one bet</h2>
<p>I&rsquo;m between jobs right now. Good position to be in if you like building things,
terrible position to be in if you like eating. So I&rsquo;ve been running a little
experiment: each weekend, pick one small idea and see if I can ship it.</p>
<p>Friday&rsquo;s job was to generate and pick an idea. I sat down with an AI and
brainstormed 20 candidates. Developer tools. Content products. Micro-SaaS. I
narrowed it down to one: a local mock server for testing OpenAI-compatible
APIs.</p>
<p>The pitch to myself was simple. I write a lot of Ruby. I write a lot of tests.
Testing code that talks to LLMs is a bit annoying because, while the happy
path is easy to mock, some of the failure modes and edge cases can be more of a
pain. There had to be a better way.</p>
<p>By end of day Friday I had a repo, a gemspec, and a clear plan.</p>
<h2 id="saturday-build-day">Saturday: build day</h2>
<p>The core idea for MockOpenAI is that it&rsquo;s a real HTTP server running on
localhost, not a mock object or a stub. Your application code talks to it
exactly the way it would talk to the LLM provider in production. You just point
your client at <code>http://localhost:4000</code> instead of the usaul API endpoint.</p>
<p>That distinction makes a difference, I think. With a real HTTP server you can
test things that object-level mocking can&rsquo;t touch: actual TCP timeouts,
truncated streaming responses, retry headers. The kind of failure modes that
bite you in production but never show up in your test suite because you stubbed
them away.</p>
<p>The architecture is deliberately simple. A Rack server reads a shared JSON
state file on every request. Your tests write rules to that file. The server is
stateless. No client wrapping, no monkey-patching, no magic.</p>
<p>Here&rsquo;s what using it looks like:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles a rate limit&#34;</span><span class="p">,</span> <span class="ss">:mock_openai_rate_limit</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="no">MyService</span><span class="o">.</span><span class="n">call_llm</span><span class="p">(</span><span class="s2">&#34;Hello&#34;</span><span class="p">)</span> <span class="p">}</span><span class="o">.</span><span class="n">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">RubyLLM</span><span class="o">::</span><span class="no">RateLimitError</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles mixed outcomes&#34;</span><span class="p">,</span> <span class="ss">:mock_openai</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">MockOpenAI</span><span class="o">.</span><span class="n">set_responses</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 1&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 2&#34;</span><span class="p">,</span> <span class="ss">failure_mode</span><span class="p">:</span> <span class="ss">:timeout</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 3&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;Done&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="no">MyService</span><span class="o">.</span><span class="n">step1</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">eq</span><span class="p">(</span><span class="s2">&#34;OK&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="no">MyService</span><span class="o">.</span><span class="n">step2</span> <span class="p">}</span><span class="o">.</span><span class="n">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">Timeout</span><span class="o">::</span><span class="no">Error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="no">MyService</span><span class="o">.</span><span class="n">step3</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">eq</span><span class="p">(</span><span class="s2">&#34;Done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The failure modes are:</p>
<table>
	<thead>
			<tr>
					<th>Mode</th>
					<th>What it does</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>:timeout</code></td>
					<td>Sleeps then closes the connection without responding</td>
			</tr>
			<tr>
					<td><code>:rate_limit</code></td>
					<td>Returns HTTP 429 with an OpenAI-format error body</td>
			</tr>
			<tr>
					<td><code>:malformed_json</code></td>
					<td>Returns truncated JSON that causes a parse error in your client</td>
			</tr>
			<tr>
					<td><code>:internal_error</code></td>
					<td>Returns HTTP 500</td>
			</tr>
			<tr>
					<td><code>:truncated_stream</code></td>
					<td>Sends partial SSE chunks then closes the connection</td>
			</tr>
	</tbody>
</table>
<p>You can also mix success and failure in a single test:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles mixed outcomes&#34;</span><span class="p">,</span> <span class="ss">:mock_openai</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">MockOpenAI</span><span class="o">.</span><span class="n">set_responses</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 1&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 2&#34;</span><span class="p">,</span> <span class="ss">failure_mode</span><span class="p">:</span> <span class="ss">:timeout</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 3&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;Done&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Saturday was productive. By end of day I had all the core classes written
TDD-style: <code>Config</code>, <code>State</code>, <code>Matcher</code>, <code>ResponseBuilder</code>,
<code>TemplateRenderer</code>, all five failure mode classes. The code was written, the
tests passed, and life was good.</p>
<h2 id="sunday-documentation-and-shipping">Sunday: documentation and shipping</h2>
<p>Sunday was docs day. I set up a Jekyll site and wrote the README. I added an
Anthropic endpoint too, because my personal projects use both.</p>
<p>I also migrated the first personal project to use MockOpenAI. That went
smoothly. The HTTP-level fidelity made a few tests a little more honest than
they&rsquo;d been with simple stubs at the client level.</p>
<h2 id="monday-the-uncomfortable-question">Monday: the uncomfortable question</h2>
<p>Monday I migrated a second personal project. This one used a helper module I&rsquo;d
written a while back to stub LLM calls. Just a few lines of code. It worked fine
for that project.</p>
<p>I stared at that code for a while. Here it is:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">RubyLLMMocks</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">mock_ruby_llm_chat</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span> <span class="ss">error</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">error</span>
</span></span><span class="line"><span class="cl">      <span class="n">allow</span><span class="p">(</span><span class="no">RubyLLM</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:chat</span><span class="p">)</span><span class="o">.</span><span class="n">and_raise</span><span class="p">(</span><span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">mock_response</span> <span class="o">=</span> <span class="n">instance_double</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Message</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">content</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nb">inspect</span><span class="p">:</span> <span class="s2">&#34;RubyLLM::Message(content: </span><span class="si">#{</span><span class="n">content</span><span class="o">.</span><span class="n">inspect</span><span class="si">}</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">mock_chat</span> <span class="o">=</span> <span class="n">instance_double</span><span class="p">(</span><span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="p">,</span> <span class="ss">ask</span><span class="p">:</span> <span class="n">mock_response</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">allow</span><span class="p">(</span><span class="no">RubyLLM</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:chat</span><span class="p">)</span><span class="o">.</span><span class="n">and_return</span><span class="p">(</span><span class="n">mock_chat</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Example:</span>
</span></span><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles general ruby_llm errors gracefully&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">error</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Error</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="kp">nil</span><span class="p">,</span> <span class="s2">&#34;Unexpected error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">mock_ruby_llm_chat</span><span class="p">(</span><span class="ss">error</span><span class="p">:</span> <span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">generator</span> <span class="o">=</span> <span class="n">described_class</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">options</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="n">generator</span><span class="o">.</span><span class="n">generate</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">to</span> <span class="n">output</span><span class="p">(</span><span class="sr">/Error.*Unexpected error/m</span><span class="p">)</span><span class="o">.</span><span class="n">to_stdout</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">and</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">SystemExit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>That&rsquo;s it. Fifteen lines, no gem dependency, works perfectly for a project that
uses RubyLLM as a wrapper. The error case is handled with <code>and_raise</code>. Clean.</p>
<p>So the question had to be asked: did I just build a solution in search of a
problem?</p>
<p>After sitting with it, I don&rsquo;t think so. But I did have to sharpen my thinking
about <em>when</em> MockOpenAI actually earns its place versus when a helper method is
the right call.</p>
<p>The short version: if you&rsquo;re using a wrapper library like RubyLLM for all your
LLM calls, and you only need happy-path responses and exception simulation in
unit tests, the 15-line helper is probably the right answer. It&rsquo;s less to
maintain, has no extra dependencies, and does the job.</p>
<p>MockOpenAI is the right call when you need the actual HTTP layer in the
picture. When you&rsquo;re using the raw OpenAI or Anthropic client directly. When
you&rsquo;re running integration or system tests that make real HTTP connections.
When you need to test what happens when TCP actually times out, or when a
streaming response gets cut off halfway through, or when your retry logic
processes a <code>Retry-After</code> header.</p>
<p>Those are real problems. They&rsquo;re just not every project&rsquo;s problems. I added a
<a href="https://grymoire7.github.io/mockopenai/when-not-to-use.html" target="_blank" rel="noopener noreffer ">when not to use</a>
page to the docs to make the tradeoffs explicit.</p>
<h2 id="what-id-do-differently">What I&rsquo;d do differently</h2>
<p>One thing I&rsquo;d change: I&rsquo;d research the problem space a bit more to make sure I
had a better understanding of problem scope and existing solution. (Especially
ones I wrote myself!) The tool is solid, but I made some assumptions about the
breadth of problems it would solve for. That&rsquo;s a classic weekend MVP trap I
suppose. You&rsquo;re so focused on building that you skip the a bit of due diligence
you think you don&rsquo;t need.</p>
<p>The gem is published, the docs are live, and it works. The scope is narrower
than I originally thought, but the use case is real. That feels like an honest
result for a long weekend.</p>
<hr>
<p><em>MockOpenAI is a Ruby gem for testing OpenAI-compatible and Anthropic APIs
locally. References:</em></p>
<ul>
<li>Source: <a href="https://github.com/grymoire7/mockopenai" target="_blank" rel="noopener noreffer ">github.com/grymoire7/mockopenai</a>.</li>
<li>Docs: <a href="https://grymoire7.github.io/mockopenai" target="_blank" rel="noopener noreffer ">grymoire7.github.io/mockopenai</a>.</li>
<li>Landing page: <a href="https://tracyatteberry.com/mockopenai" target="_blank" rel="noopener noreffer ">tracyatteberry.com/mockopenai</a>.</li>
</ul>
]]></description></item><item><title>Search with AI four ways: How much AI does your Rails app really need</title><link>https://tracyatteberry.com/posts/search_four_ways/</link><pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/search_four_ways/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/search_four_ways/coder.jpg" referrerpolicy="no-referrer">
            </div><h1 id="documentation-search-when-your-rails-app-needs-which-approach">Documentation search: When your Rails app needs which approach</h1>
<p>Imagine spending an afternoon watching a developer tear out a perfectly
functional search feature. They replace their solid Postgres full-text
search with a vector database and RAG pipeline because, well, that&rsquo;s what
you&rsquo;re supposed to do now, right? The new system is slower, cost them $200 a
month in OpenAI API calls, and returned worse results for their specific use
case.</p>
<p>This keeps happening. The AI hype cycle has convinced developers that every
search problem needs embeddings, vector databases, and agentic loops. Sometimes
that&rsquo;s true. Often it&rsquo;s not.</p>
<p>Let&rsquo;s build the same feature four different ways and see what each approach
actually costs you.</p>
<h2 id="the-use-case-searching-ruby-gem-documentation">The use case: searching Ruby gem documentation</h2>
<p>We&rsquo;re building a search feature for a documentation site that indexes about
5,000 Ruby gems. Each gem has README content, API documentation, and code
examples. Users ask questions like &ldquo;How do I upload files to S3?&rdquo; or &ldquo;What&rsquo;s
the best gem for handling webhooks?&rdquo;</p>
<p>This is a realistic scale for most Rails apps. Not Google-sized, not trivial.
Just normal business software that needs to help users find information.</p>
<p>I&rsquo;ll show you four implementations, each adding a layer of complexity. We&rsquo;ll
look at the code, measure the actual costs, and figure out when the added
complexity pays for itself.</p>
<h2 id="approach-1-traditional-search-with-ai-summarization">Approach 1: Traditional search with AI summarization</h2>
<p>Start with what works. Postgres full-text search has been solving search
problems since before your junior devs were born.</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Documentation</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="kp">include</span> <span class="no">PgSearch</span><span class="o">::</span><span class="no">Model</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">pg_search_scope</span> <span class="ss">:search_content</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">against</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">title</span><span class="p">:</span> <span class="s1">&#39;A&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">content</span><span class="p">:</span> <span class="s1">&#39;B&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">code_examples</span><span class="p">:</span> <span class="s1">&#39;C&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="ss">using</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">tsearch</span><span class="p">:</span> <span class="p">{</span> <span class="ss">prefix</span><span class="p">:</span> <span class="kp">true</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="ss">trigram</span><span class="p">:</span> <span class="p">{</span> <span class="ss">threshold</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DocumentationSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@query</span> <span class="o">=</span> <span class="n">query</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">search_content</span><span class="p">(</span><span class="vi">@query</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">results</span><span class="p">:</span> <span class="n">results</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">summary</span><span class="p">:</span> <span class="n">summarize_results</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">summarize_results</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kp">nil</span> <span class="k">if</span> <span class="n">results</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">prompt</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">      <span class="no">User</span> <span class="ss">question</span><span class="p">:</span> <span class="c1">#{@query}</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">Here</span> <span class="n">are</span> <span class="n">the</span> <span class="n">top</span> <span class="n">search</span> <span class="ss">results</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="c1">#{format_results(results)}</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">Provide</span> <span class="n">a</span> <span class="n">concise</span> <span class="n">answer</span> <span class="n">to</span> <span class="n">the</span> <span class="n">user</span><span class="s1">&#39;s question based on these results.
</span></span></span><span class="line"><span class="cl"><span class="s1">      If the results don&#39;</span><span class="n">t</span> <span class="n">contain</span> <span class="n">relevant</span> <span class="n">information</span><span class="p">,</span> <span class="n">say</span> <span class="n">so</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">    <span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">client</span> <span class="o">=</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span>
</span></span><span class="line"><span class="cl">    <span class="n">client</span> <span class="o">=</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;gpt-4o-mini&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">messages</span><span class="p">:</span> <span class="o">[</span><span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">prompt</span> <span class="p">}</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">temperature</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:message</span><span class="p">,</span> <span class="ss">:content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">format_results</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span><span class="o">.</span><span class="n">map</span><span class="o">.</span><span class="n">with_index</span> <span class="k">do</span> <span class="o">|</span><span class="n">doc</span><span class="p">,</span> <span class="n">i</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;</span><span class="si">#{</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="si">}</span><span class="s2">. </span><span class="si">#{</span><span class="n">doc</span><span class="o">.</span><span class="n">title</span><span class="si">}</span><span class="se">\n</span><span class="si">#{</span><span class="n">doc</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">truncate</span><span class="p">(</span><span class="mi">500</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This approach does one database query and one API call. The search uses proven
Postgres features: full-text search with ranking, trigram matching for typos,
and weighted fields. Then we send the top results to GPT-4 to generate a
summary.</p>
<p><strong>Cost per query:</strong></p>
<ul>
<li>Database: ~5ms</li>
<li>OpenAI API: ~$0.002 (about 1,000 input tokens, 200 output tokens)</li>
<li>Total latency: ~800ms</li>
</ul>
<p><strong>When this fails:</strong></p>
<ul>
<li>User queries are conceptually different from how docs are written (&ldquo;async jobs&rdquo; versus &ldquo;background processing&rdquo;)</li>
<li>Important information is buried in the middle of long documents</li>
<li>You need to combine information from multiple sources</li>
</ul>
<p>The failure mode is subtle. Traditional search ranks by keyword matching and
field weights. When users phrase questions differently than your documentation
uses terminology, they get poor results. You can&rsquo;t fix this with better prompt
engineering because the LLM never sees the relevant documents.</p>
<h2 id="approach-2-basic-rag-with-vector-embeddings">Approach 2: Basic RAG with vector embeddings</h2>
<p>RAG (Retrieval Augmented Generation) means embedding your documents as vectors,
embedding the user&rsquo;s query as a vector, and finding documents with similar
embeddings. This solves the terminology mismatch problem.</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># We need to store embeddings</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">AddEmbeddingsToDocumentation</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="o">[</span><span class="mi">7</span><span class="o">.</span><span class="mi">1</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">change</span>
</span></span><span class="line"><span class="cl">    <span class="n">add_column</span> <span class="ss">:documentations</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">:vector</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">1536</span>
</span></span><span class="line"><span class="cl">    <span class="n">add_index</span> <span class="ss">:documentations</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">using</span><span class="p">:</span> <span class="ss">:hnsw</span><span class="p">,</span> <span class="ss">opclass</span><span class="p">:</span> <span class="ss">:vector_cosine_ops</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Documentation</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="n">has_neighbors</span> <span class="ss">:embedding</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">after_save</span> <span class="ss">:generate_embedding</span><span class="p">,</span> <span class="k">if</span><span class="p">:</span> <span class="ss">:content_changed?</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_embedding</span>
</span></span><span class="line"><span class="cl">    <span class="n">text</span> <span class="o">=</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="se">\n\n</span><span class="si">#{</span><span class="n">content</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">client</span> <span class="o">=</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">embeddings</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;text-embedding-3-small&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">input</span><span class="p">:</span> <span class="n">text</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nb">self</span><span class="o">.</span><span class="n">update_column</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:data</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">RagSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="ss">client</span><span class="p">:</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@query</span> <span class="o">=</span> <span class="n">query</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@client</span> <span class="o">=</span> <span class="n">client</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">query_embedding</span> <span class="o">=</span> <span class="n">generate_embedding</span><span class="p">(</span><span class="vi">@query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">query_embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">results</span><span class="p">:</span> <span class="n">results</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">answer</span><span class="p">:</span> <span class="n">generate_answer</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">embeddings</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;text-embedding-3-small&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">input</span><span class="p">:</span> <span class="n">text</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:data</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_answer</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">context</span> <span class="o">=</span> <span class="n">results</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">doc</span><span class="o">|</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">doc</span><span class="o">.</span><span class="n">title</span><span class="si">}</span><span class="se">\n</span><span class="si">#{</span><span class="n">doc</span><span class="o">.</span><span class="n">content</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">---</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">prompt</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">      <span class="no">Answer</span> <span class="n">the</span> <span class="n">user</span><span class="err">&#39;</span><span class="n">s</span> <span class="n">question</span> <span class="n">based</span> <span class="n">only</span> <span class="n">on</span> <span class="n">the</span> <span class="n">following</span> <span class="ss">documentation</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="c1">#{context}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="ss">Question</span><span class="p">:</span> <span class="c1">#{@query}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="no">If</span> <span class="n">you</span> <span class="n">cannot</span> <span class="n">answer</span> <span class="n">based</span> <span class="n">on</span> <span class="n">the</span> <span class="n">provided</span> <span class="n">documentation</span><span class="p">,</span> <span class="n">say</span> <span class="n">so</span> <span class="n">clearly</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">    <span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;gpt-4o-mini&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">messages</span><span class="p">:</span> <span class="o">[</span><span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">prompt</span> <span class="p">}</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">temperature</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:message</span><span class="p">,</span> <span class="ss">:content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Now we&rsquo;re making two API calls per search: one to embed the query, one to
generate the answer. We&rsquo;re also using pgvector with HNSW indexing for fast
similarity search.</p>
<p><strong>Cost per query:</strong></p>
<ul>
<li>Database: ~15ms (vector similarity search)</li>
<li>OpenAI embeddings API: ~$0.00001 (negligible)</li>
<li>OpenAI chat API: ~$0.003</li>
<li>Total latency: ~1,200ms</li>
</ul>
<p><strong>When this works better:</strong>
The semantic matching is noticeably better. A query about &ldquo;background jobs&rdquo;
will match documents about &ldquo;async processing&rdquo; and &ldquo;delayed tasks&rdquo; because the
concepts are similar in vector space. This is real improvement over keyword
search.</p>
<p><strong>When this still fails:</strong></p>
<ul>
<li>Complex questions requiring information from many documents</li>
<li>Multi-step reasoning (&ldquo;compare these two approaches&rdquo;)</li>
<li>Questions where the first retrieval doesn&rsquo;t get the right context</li>
</ul>
<p>Here&rsquo;s a concrete failure case I hit: A user asks &ldquo;What&rsquo;s the difference
between Sidekiq and Good Job?&rdquo; The vector search retrieves five documents, but
three are about Sidekiq and two are about Good Job. The LLM tries to compare
them but doesn&rsquo;t have complete information about both systems. It hedges and
gives a vague answer.</p>
<h2 id="approach-3-agentic-rag-with-adaptive-retrieval">Approach 3: Agentic RAG with adaptive retrieval</h2>
<p>This is where we let the LLM decide if it needs more information. Instead of
one retrieve-then-generate pass, we give the LLM tools to search again,
rephrase queries, or combine results.</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">AgenticRagSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_ITERATIONS</span> <span class="o">=</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="ss">client</span><span class="p">:</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@query</span> <span class="o">=</span> <span class="n">query</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@client</span> <span class="o">=</span> <span class="n">client</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">=</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@retrieved_docs</span> <span class="o">=</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">initial_prompt</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">      <span class="no">You</span> <span class="n">are</span> <span class="n">a</span> <span class="n">helpful</span> <span class="n">assistant</span> <span class="n">that</span> <span class="n">searches</span> <span class="no">Ruby</span> <span class="n">gem</span> <span class="n">documentation</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">User</span> <span class="ss">question</span><span class="p">:</span> <span class="c1">#{@query}</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">You</span> <span class="n">have</span> <span class="n">access</span> <span class="n">to</span> <span class="n">these</span> <span class="ss">tools</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="o">-</span> <span class="n">search_docs</span><span class="p">(</span><span class="n">query</span><span class="p">):</span> <span class="no">Search</span> <span class="n">documentation</span> <span class="n">with</span> <span class="n">a</span> <span class="n">semantic</span> <span class="n">query</span>
</span></span><span class="line"><span class="cl">      <span class="o">-</span> <span class="n">get_related</span><span class="p">(</span><span class="n">doc_id</span><span class="p">):</span> <span class="no">Get</span> <span class="n">documents</span> <span class="n">related</span> <span class="n">to</span> <span class="n">a</span> <span class="n">specific</span> <span class="n">document</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">Think</span> <span class="n">step</span> <span class="n">by</span> <span class="n">step</span><span class="o">.</span> <span class="no">You</span> <span class="n">can</span> <span class="n">search</span> <span class="n">multiple</span> <span class="n">times</span> <span class="n">with</span> <span class="n">different</span> <span class="n">queries</span>
</span></span><span class="line"><span class="cl">      <span class="n">to</span> <span class="n">gather</span> <span class="n">complete</span> <span class="n">information</span> <span class="n">before</span> <span class="n">answering</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">    <span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">&lt;&lt;</span> <span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">initial_prompt</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="no">MAX_ITERATIONS</span><span class="o">.</span><span class="n">times</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">response</span> <span class="o">=</span> <span class="n">call_llm_with_tools</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="k">break</span> <span class="k">if</span> <span class="n">response</span><span class="o">[</span><span class="ss">:finish_reason</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;stop&#34;</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="n">response</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">handle_tool_calls</span><span class="p">(</span><span class="n">response</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">results</span><span class="p">:</span> <span class="vi">@retrieved_docs</span><span class="o">.</span><span class="n">uniq</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">answer</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">last</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">iterations</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">length</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">call_llm_with_tools</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;gpt-4o&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">messages</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">tools</span><span class="p">:</span> <span class="n">tool_definitions</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">temperature</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">message</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">&lt;&lt;</span> <span class="n">message</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">finish_reason</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:finish_reason</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">tool_calls</span><span class="p">:</span> <span class="n">message</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">tool_definitions</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;search_docs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Search documentation using semantic search&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">query</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;The search query&#34;</span>
</span></span><span class="line"><span class="cl">              <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;get_related&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Get documents related to a specific document&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">doc_id</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;integer&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;The ID of the document&#34;</span>
</span></span><span class="line"><span class="cl">              <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;doc_id&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">handle_tool_calls</span><span class="p">(</span><span class="n">tool_calls</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">tool_calls</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">tool_call</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">function_name</span> <span class="o">=</span> <span class="n">tool_call</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:function</span><span class="p">,</span> <span class="ss">:name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">arguments</span> <span class="o">=</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">tool_call</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:function</span><span class="p">,</span> <span class="ss">:arguments</span><span class="p">)</span> <span class="o">||</span> <span class="s2">&#34;{}&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="n">result</span> <span class="o">=</span> <span class="k">case</span> <span class="n">function_name</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;search_docs&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">search_docs</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;get_related&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">get_related_docs</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;doc_id&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="vi">@retrieved_docs</span><span class="o">.</span><span class="n">concat</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;tool&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">tool_call_id</span><span class="p">:</span> <span class="n">tool_call</span><span class="o">[</span><span class="ss">:id</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">format_docs_for_llm</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">concat</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search_docs</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">embedding</span> <span class="o">=</span> <span class="n">generate_embedding</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Documentation</span><span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">get_related_docs</span><span class="p">(</span><span class="n">doc_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="n">doc_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Documentation</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">doc</span><span class="o">.</span><span class="n">embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">where</span><span class="o">.</span><span class="n">not</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="n">doc_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">embeddings</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;text-embedding-3-small&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">input</span><span class="p">:</span> <span class="n">text</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:data</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">format_docs_for_llm</span><span class="p">(</span><span class="n">docs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">docs</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">doc</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">id</span><span class="p">:</span> <span class="n">doc</span><span class="o">.</span><span class="n">id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">title</span><span class="p">:</span> <span class="n">doc</span><span class="o">.</span><span class="n">title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">doc</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">truncate</span><span class="p">(</span><span class="mi">1000</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span><span class="o">.</span><span class="n">to_json</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This is a real step up in complexity. We&rsquo;re now orchestrating multiple LLM
calls with tool use. The LLM can search multiple times, explore related
documents, and build up context before answering.</p>
<p><strong>Cost per query:</strong></p>
<ul>
<li>Database: 30-90ms (multiple vector searches)</li>
<li>OpenAI embeddings API: $0.00002-0.00006 (2-6 embedding calls)</li>
<li>OpenAI chat API: $0.015-0.045 (3-5 LLM calls with larger context)</li>
<li>Total latency: 3-8 seconds</li>
</ul>
<p>Notice the variance. Some queries get answered in one iteration. Complex ones
might do three searches with different phrasings, explore related documents,
and make five total LLM calls.</p>
<p><strong>When this works better:</strong>
That comparison query from before (&ldquo;What&rsquo;s the difference between Sidekiq and
Good Job?&rdquo;) now works great. The LLM searches for &ldquo;Sidekiq background jobs&rdquo;,
gets those docs, then searches for &ldquo;Good Job background jobs&rdquo;, gets those docs,
then synthesizes a real comparison.</p>
<p>Multi-part questions work too. &ldquo;How do I set up Stripe payments and handle
webhooks?&rdquo; triggers two separate searches that gather comprehensive
information.</p>
<p><strong>When this gets expensive:</strong>
Every query where the LLM decides it needs more information costs you 3-5x
more. If your users ask a lot of complex questions, your API bill climbs fast.</p>
<p>The latency is also noticeable. Eight seconds feels slow in a web UI. You need
to stream responses or show progress indicators.</p>
<h2 id="approach-4-full-conversational-agent-with-external-tools">Approach 4: Full conversational agent with external tools</h2>
<p>Now we&rsquo;re building a real agent that can search your documentation, browse
external sites, and maintain conversation context across multiple turns.</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DocumentationAgent</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">session_id</span><span class="p">,</span> <span class="ss">client</span><span class="p">:</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@session_id</span> <span class="o">=</span> <span class="n">session_id</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@client</span> <span class="o">=</span> <span class="n">client</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">=</span> <span class="n">load_conversation_history</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">chat</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">&lt;&lt;</span> <span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">message</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="kp">loop</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">response</span> <span class="o">=</span> <span class="n">call_llm_with_tools</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="k">break</span> <span class="k">if</span> <span class="n">response</span><span class="o">[</span><span class="ss">:finish_reason</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;stop&#34;</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="n">response</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">handle_tool_calls</span><span class="p">(</span><span class="n">response</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">else</span>
</span></span><span class="line"><span class="cl">        <span class="k">break</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">save_conversation_history</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">response</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">last</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">sources</span><span class="p">:</span> <span class="n">extract_sources</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">call_llm_with_tools</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;gpt-4o&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">messages</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">tools</span><span class="p">:</span> <span class="n">tool_definitions</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">temperature</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">message</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">&lt;&lt;</span> <span class="n">message</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">finish_reason</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:finish_reason</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">tool_calls</span><span class="p">:</span> <span class="n">message</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">embeddings</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;text-embedding-3-small&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">input</span><span class="p">:</span> <span class="n">text</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:data</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">tool_definitions</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;search_internal_docs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Search our Ruby gem documentation&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">query</span><span class="p">:</span> <span class="p">{</span> <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;fetch_external_url&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Fetch content from an external URL like GitHub or RubyGems.org&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">url</span><span class="p">:</span> <span class="p">{</span> <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;url&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;search_github&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Search GitHub repositories and code&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">query</span><span class="p">:</span> <span class="p">{</span> <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">handle_tool_calls</span><span class="p">(</span><span class="n">tool_calls</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">tool_calls</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">tool_call</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">function_name</span> <span class="o">=</span> <span class="n">tool_call</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:function</span><span class="p">,</span> <span class="ss">:name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">arguments</span> <span class="o">=</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">tool_call</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:function</span><span class="p">,</span> <span class="ss">:arguments</span><span class="p">)</span> <span class="o">||</span> <span class="s2">&#34;{}&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="n">result</span> <span class="o">=</span> <span class="k">case</span> <span class="n">function_name</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;search_internal_docs&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">search_internal_docs</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;fetch_external_url&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">fetch_external_url</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;url&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;search_github&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">search_github</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;tool&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">tool_call_id</span><span class="p">:</span> <span class="n">tool_call</span><span class="o">[</span><span class="ss">:id</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">result</span><span class="o">.</span><span class="n">to_json</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">concat</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search_internal_docs</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">embedding</span> <span class="o">=</span> <span class="n">generate_embedding</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">docs</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">docs</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">d</span><span class="o">|</span> <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">d</span><span class="o">.</span><span class="n">title</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">d</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">truncate</span><span class="p">(</span><span class="mi">800</span><span class="p">),</span> <span class="ss">source</span><span class="p">:</span> <span class="s2">&#34;internal&#34;</span><span class="p">,</span> <span class="nb">id</span><span class="p">:</span> <span class="n">d</span><span class="o">.</span><span class="n">id</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">fetch_external_url</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># In production, use a proper HTTP client with timeouts and error handling</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="no">HTTP</span><span class="o">.</span><span class="n">timeout</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">content</span><span class="p">:</span> <span class="n">extract_main_content</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span><span class="o">.</span><span class="n">truncate</span><span class="p">(</span><span class="mi">2000</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">source</span><span class="p">:</span> <span class="s2">&#34;external&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">HTTP</span><span class="o">::</span><span class="no">Error</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">error</span><span class="p">:</span> <span class="s2">&#34;Failed to fetch URL: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search_github</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Use Octokit or similar</span>
</span></span><span class="line"><span class="cl">    <span class="n">client</span> <span class="o">=</span> <span class="no">Octokit</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">access_token</span><span class="p">:</span> <span class="no">ENV</span><span class="o">[</span><span class="s1">&#39;GITHUB_TOKEN&#39;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">search_code</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="ss">language</span><span class="p">:</span> <span class="s2">&#34;ruby&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">results</span><span class="o">.</span><span class="n">items</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">name</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">name</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">repo</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">repository</span><span class="o">.</span><span class="n">full_name</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">url</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">html_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">source</span><span class="p">:</span> <span class="s2">&#34;github&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">Octokit</span><span class="o">::</span><span class="no">Error</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">error</span><span class="p">:</span> <span class="s2">&#34;GitHub search failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">load_conversation_history</span>
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;agent_conversation:</span><span class="si">#{</span><span class="vi">@session_id</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">cache_key</span><span class="p">)</span> <span class="o">||</span> <span class="s2">&#34;[]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">save_conversation_history</span>
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;agent_conversation:</span><span class="si">#{</span><span class="vi">@session_id</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Keep last 10 messages to control context size</span>
</span></span><span class="line"><span class="cl">    <span class="n">trimmed_history</span> <span class="o">=</span> <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">last</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="n">cache_key</span><span class="p">,</span> <span class="n">trimmed_history</span><span class="o">.</span><span class="n">to_json</span><span class="p">,</span> <span class="ss">expires_in</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">extract_sources</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span> <span class="n">msg</span><span class="o">[</span><span class="ss">:role</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;tool&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">flat_map</span> <span class="p">{</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">msg</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span> <span class="o">||</span> <span class="s2">&#34;[]&#34;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span> <span class="n">item</span><span class="o">.</span><span class="n">is_a?</span><span class="p">(</span><span class="no">Hash</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">item</span><span class="o">[</span><span class="s2">&#34;source&#34;</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">uniq</span> <span class="p">{</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span> <span class="n">item</span><span class="o">[</span><span class="s2">&#34;id&#34;</span><span class="o">]</span> <span class="o">||</span> <span class="n">item</span><span class="o">[</span><span class="s2">&#34;url&#34;</span><span class="o">]</span> <span class="o">||</span> <span class="n">item</span><span class="o">[</span><span class="s2">&#34;title&#34;</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This is a different beast. We&rsquo;re maintaining conversation state, hitting
external APIs, and letting the LLM orchestrate complex research tasks.</p>
<p><strong>Cost per conversation turn:</strong></p>
<ul>
<li>Database: 15-50ms</li>
<li>External API calls: 200-2000ms (GitHub, external sites)</li>
<li>OpenAI embeddings: $0.00001-0.00005</li>
<li>OpenAI chat: $0.02-0.15 (larger context windows, multiple turns)</li>
<li>Total latency: 5-15 seconds</li>
</ul>
<p><strong>When this is worth it:</strong>
You&rsquo;re building a research assistant or technical support bot where users have
complex, multi-turn conversations. They ask follow-up questions, need you to
check external sources, and expect the system to remember context.</p>
<p>A user might ask &ldquo;What&rsquo;s the best gem for image processing?&rdquo;, then follow up
with &ldquo;Show me examples from the ImageMagick wrapper&rdquo;, then &ldquo;Is there a more
modern alternative?&rdquo; The agent maintains context and can search different
sources for each question.</p>
<p><strong>When this is overkill:</strong>
Most search features. If users are doing one-off queries and moving on, you&rsquo;re
paying for conversational capabilities they don&rsquo;t need.</p>
<h2 id="choosing-your-approach">Choosing your approach</h2>
<p>I&rsquo;ve built all four of these systems in production. Here&rsquo;s how I decide which
to use.</p>
<p><strong>Start with enhanced traditional search if:</strong></p>
<ul>
<li>You have well-written documentation with consistent terminology</li>
<li>Queries are mostly straightforward lookup tasks</li>
<li>You need predictable costs and latency</li>
<li>Your document corpus is under 10,000 items</li>
</ul>
<p>The cost difference matters. At 1,000 queries per day, enhanced traditional
search costs you $2/day. Basic RAG costs $3/day. Agentic RAG costs $15-45/day.
A full agent costs $100-300/day.</p>
<p><strong>Move to basic RAG when:</strong></p>
<ul>
<li>Users phrase questions differently than your docs</li>
<li>Keyword search returns poor matches for valid queries</li>
<li>You have good quality source documents</li>
<li>Your corpus is large enough that keyword search becomes unwieldy (50,000+ documents)</li>
</ul>
<p>You&rsquo;ll know you need this when users complain that search doesn&rsquo;t work, and you
look at their queries and think &ldquo;we have docs about that, but they&rsquo;re using
different words.&rdquo;</p>
<p><strong>Move to agentic RAG when:</strong></p>
<ul>
<li>Users ask complex questions requiring multiple sources</li>
<li>You see patterns of users doing multiple searches in sequence</li>
<li>Simple RAG returns incomplete answers</li>
<li>You have budget for 3-5x higher API costs</li>
</ul>
<p>Watch your analytics. If users do three searches in a row and then give up,
they&rsquo;re manually doing what an agentic system would do automatically.</p>
<p><strong>Build a full agent when:</strong></p>
<ul>
<li>You&rsquo;re building a product feature, not just search</li>
<li>Users need multi-turn conversations with context</li>
<li>You need to integrate external data sources</li>
<li>You have engineering resources for proper tool integration and error handling</li>
</ul>
<p>The engineering complexity here is significant. You need proper timeout
handling, retry logic, conversation state management, and graceful degradation
when external APIs fail. This is a feature, not a quick enhancement.</p>
<h2 id="the-implementation-details-that-matter">The implementation details that matter</h2>
<p>Some practical considerations that aren&rsquo;t obvious from the code samples.</p>
<p><strong>Chunking strategy for vector search:</strong>
Don&rsquo;t just embed entire documents. Break them into logical chunks. For
documentation, consider chunking by section with overlap:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DocumentationChunker</span>
</span></span><span class="line"><span class="cl">  <span class="no">CHUNK_SIZE</span> <span class="o">=</span> <span class="mi">1000</span> <span class="c1"># characters</span>
</span></span><span class="line"><span class="cl">  <span class="no">OVERLAP</span> <span class="o">=</span> <span class="mi">200</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">chunk</span><span class="p">(</span><span class="n">document</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">sections</span> <span class="o">=</span> <span class="n">document</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="sr">/^##\s+/</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">sections</span><span class="o">.</span><span class="n">flat_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">section</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">break_into_overlapping_chunks</span><span class="p">(</span><span class="n">section</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">break_into_overlapping_chunks</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span> <span class="o">=</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl">    <span class="n">start</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">start</span> <span class="o">&lt;</span> <span class="n">text</span><span class="o">.</span><span class="n">length</span>
</span></span><span class="line"><span class="cl">      <span class="n">chunk_end</span> <span class="o">=</span> <span class="n">start</span> <span class="o">+</span> <span class="no">CHUNK_SIZE</span>
</span></span><span class="line"><span class="cl">      <span class="n">chunks</span> <span class="o">&lt;&lt;</span> <span class="n">text</span><span class="o">[</span><span class="n">start</span><span class="o">...</span><span class="n">chunk_end</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">      <span class="n">start</span> <span class="o">+=</span> <span class="no">CHUNK_SIZE</span> <span class="o">-</span> <span class="no">OVERLAP</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This means each document generates multiple rows in your database with
different embeddings. Your vector search returns chunks, not whole documents.</p>
<p><strong>Hybrid search combines the best of both:</strong></p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">hybrid_search</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Keyword search results</span>
</span></span><span class="line"><span class="cl">  <span class="n">keyword_results</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">search_content</span><span class="p">(</span><span class="n">query</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="c1"># Vector search results</span>
</span></span><span class="line"><span class="cl">  <span class="n">query_embedding</span> <span class="o">=</span> <span class="n">generate_embedding</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">vector_results</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">query_embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="c1"># Combine with reciprocal rank fusion</span>
</span></span><span class="line"><span class="cl">  <span class="n">combine_results</span><span class="p">(</span><span class="n">keyword_results</span><span class="p">,</span> <span class="n">vector_results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">combine_results</span><span class="p">(</span><span class="n">keyword_results</span><span class="p">,</span> <span class="n">vector_results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">scores</span> <span class="o">=</span> <span class="no">Hash</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">keyword_results</span><span class="o">.</span><span class="n">each_with_index</span> <span class="k">do</span> <span class="o">|</span><span class="n">doc</span><span class="p">,</span> <span class="n">i</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">scores</span><span class="o">[</span><span class="n">doc</span><span class="o">.</span><span class="n">id</span><span class="o">]</span> <span class="o">+=</span> <span class="mi">1</span><span class="o">.</span><span class="mi">0</span> <span class="o">/</span> <span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">60</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">vector_results</span><span class="o">.</span><span class="n">each_with_index</span> <span class="k">do</span> <span class="o">|</span><span class="n">doc</span><span class="p">,</span> <span class="n">i</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">scores</span><span class="o">[</span><span class="n">doc</span><span class="o">.</span><span class="n">id</span><span class="o">]</span> <span class="o">+=</span> <span class="mi">1</span><span class="o">.</span><span class="mi">0</span> <span class="o">/</span> <span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">60</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="no">Documentation</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="n">scores</span><span class="o">.</span><span class="n">keys</span><span class="p">)</span><span class="o">.</span><span class="n">sort_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">doc</span><span class="o">|</span> <span class="o">-</span><span class="n">scores</span><span class="o">[</span><span class="n">doc</span><span class="o">.</span><span class="n">id</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This gives you semantic understanding from vectors plus precise keyword
matching. The reciprocal rank fusion formula is from research on combining
search results. It works better than naive score addition.</p>
<p><strong>Caching saves you money:</strong></p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">CachedRagSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;rag_search:</span><span class="si">#{</span><span class="no">Digest</span><span class="o">::</span><span class="no">MD5</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">(</span><span class="n">query</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="n">cache_key</span><span class="p">,</span> <span class="ss">expires_in</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">perform_search</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Popular queries get asked repeatedly. Cache the embeddings and the LLM
responses. This cuts your API costs dramatically for common questions.</p>
<p>The above caching method uses the raw query as the cache key. If users ask the
same question with slightly different wording, they won&rsquo;t hit the cache. You
might want to normalize queries before hashing them for the cache key. For
example, you could lowercase the query, remove stop words, etc.</p>
<p><strong>Monitor your failure modes:</strong></p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">SearchMetrics</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">track</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">approach</span><span class="p">,</span> <span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">SearchLog</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">query</span><span class="p">:</span> <span class="n">query</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">approach</span><span class="p">:</span> <span class="n">approach</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">result_count</span><span class="p">:</span> <span class="n">results</span><span class="o">.</span><span class="n">length</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">latency_ms</span><span class="p">:</span> <span class="n">results</span><span class="o">[</span><span class="ss">:latency</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">cost_cents</span><span class="p">:</span> <span class="n">calculate_cost</span><span class="p">(</span><span class="n">results</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">user_clicked</span><span class="p">:</span> <span class="kp">false</span> <span class="c1"># updated when user clicks a result</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Track which results users actually click. If they click the first result, your
search works. If they reformulate their query three times, it doesn&rsquo;t. This
data tells you whether to upgrade your approach.</p>
<h2 id="what-i-actually-recommend">What I actually recommend</h2>
<p>Build the simplest thing first. Most Rails apps should start with Postgres
full-text search plus GPT summarization. It costs almost nothing, has
predictable latency, and works fine for straightforward queries.</p>
<p>Add instrumentation immediately. Track user behavior, measure latency, and
watch your API costs. You need this data to know if upgrading is worth it.</p>
<p>When you see concrete evidence that simple search fails for your use case, add
vector embeddings. This is a real improvement for semantic search. The pgvector
extension makes this straightforward in Postgres. You don&rsquo;t need a separate
vector database until you have millions of documents.</p>
<p>Only add agentic features when you can point to specific query patterns that
need them. &ldquo;Users ask comparison questions and we don&rsquo;t have comparison docs&rdquo;
is a good reason. &ldquo;Agents are cool and I want to try them&rdquo; is not.</p>
<p>Save full conversational agents for when you&rsquo;re building a product feature that
needs conversations. This is engineering work, not just adding a library.
Budget for it appropriately.</p>
<p>The hype cycle pushes developers toward complexity. Resist it. Your users don&rsquo;t
care about your architecture. They care about getting answers quickly and
cheaply. Often the simplest approach gives them exactly that.</p>
<hr>
<p><strong>But does the code work?</strong> See <a href="https://github.com/grymoire7/rails_search" target="_blank" rel="noopener noreffer ">this
repo</a> for an implementation of all
four approaches.</p>
<hr>
<p><strong>Disclaimer:</strong> The code samples in this post are simplified for clarity. In
production, you need proper error handling, timeouts, retries, and security
considerations (e.g., sanitizing user input before embedding). The costs and
latencies mentioned are estimates based on recent OpenAI pricing and typical
response times; your actual costs may vary based on usage patterns and model
choices. Always monitor your API usage and costs when deploying AI features.
Void where prohibited.</p>
]]></description></item><item><title>Blame Claude</title><link>https://tracyatteberry.com/notes/blame_claude/</link><pubDate>Fri, 06 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/notes/blame_claude/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/notes/blame_claude/blame.jpg" referrerpolicy="no-referrer">
            </div><p>The debate about how much credit to give AI tools for their contributions to
software development has been going on for a while now. I only recently, however,
became aware of how academic publishers have been handling this issue in the
context of research papers.</p>
<p>A number of academic publishers have policies stating that 1) use of AI
tools must be disclosed, and 2) AI tools cannot be listed as authors. Here&rsquo;s an
example of the latter from Elsevier Books:</p>
<blockquote>
<p>Authors should not list generative AI and AI-assisted technologies as an
author or co-author, nor cite AI as an author. Authorship implies
responsibilities and tasks that can only be attributed to and performed by
humans&hellip;[1]</p>
</blockquote>
<p>There are good arguments on both sides of the debate, but I tend to agree with
the spirit of this policy and would extend it to software development as well.</p>
<p>If I submit code, I am responsible for that code. Full stop. The AI is not an
author, it&rsquo;s not a co-author, and it doesn&rsquo;t get a byline. I don&rsquo;t want to be
able to say &ldquo;well, the AI wrote that part, so it&rsquo;s not really my fault if it
has a bug.&rdquo; If I use an AI tool to generate code, I am responsible for
reviewing that code, making sure it meets the coding standards, and being able
to defend and explain it to my teammates. If I vibe code a commit and send it
off to be reviewed by my team, I am now shifting all of the responsibility for
code quality onto them. That creates cognitive debt and burns trust.</p>
<p>At the same time, I think it&rsquo;s important to be transparent about the use of AI
tools. It&rsquo;s not something to be ashamed of or hide, any more than I&rsquo;d hide the
fact that I used a code generator or a linter. These days it&rsquo;s a good bet that
AI is involved at some point, and I honestly may forget to point that out every
time.  Maybe I should do better in that area, but I don&rsquo;t want to pedantic
about it. I just want to make sure that when I do use AI, I&rsquo;m taking
responsibility for the output.</p>
<p>Used well, AI can clear space for better judgment. Used poorly, it can move
more work onto others and/or create more cognitive debt.</p>
<p><strong>Note:</strong> <em>I usually have an AI look over my writing or even brainstorm the
initial draft, but I didn&rsquo;t do that this time. However, I will admit that
Copilot was on in my editor and I spent a lot of time dismissing bad
auto-complete suggestions before I finally turned it off. Okay, hopefully
that&rsquo;s enough transparency for this short post.</em></p>
<p>[1] <a href="https://libguides.tamusa.edu/AI_for_lit_research/journal_requirements" target="_blank" rel="noopener noreffer ">https://libguides.tamusa.edu/AI_for_lit_research/journal_requirements</a></p>
]]></description></item><item><title>Claude Cowork... maybe?</title><link>https://tracyatteberry.com/notes/cowork/</link><pubDate>Thu, 05 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/notes/cowork/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/notes/cowork/claude-cowork.jpg" referrerpolicy="no-referrer">
            </div><p>I was excited to try the new Cowork feature in Claude. The idea of a
persistent, safe workspace where I can collaborate with an AI over time sounded
great. I set up my space, pointed it at my screenshots directory, and asked
Claude to help me organize it. It actually worked pretty well. Claude
understood my files, suggested a new folder structure, and moved everything
around for me. Cool. What next?</p>
<p>I noticed a sample task offering to help build a background creator based on
biological principles. That sounded fun, so I gave it a shot. Claude got
started and then&hellip; nothing. Some of the presets just didn&rsquo;t work at all.</p>
<p>I asked Claude to fix it. It tried, and failed, repeatedly. The problem was it
had no way to see what the output actually looked like, so I tried to give it a
tool to help. Unfortunately, it couldn&rsquo;t install the tool — no network access —
and it also couldn&rsquo;t reach a locally installed version outside the Cowork VM.</p>
<p>The pattern that emerged was pretty clear. For tasks where Claude can get
feedback from the files in its workspace, it works pretty well. For tasks like
the background creator, where it needs to see rendered output, it falls flat.
I get the safety rationale behind the sandboxing, but I hope they can improve
the tooling enough to at least make their own examples work.</p>
]]></description></item><item><title>When 'just return JSON' isn't enough</title><link>https://tracyatteberry.com/posts/structured_output/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/structured_output/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/structured_output/structured_output.png" referrerpolicy="no-referrer">
            </div><h1 id="when-just-return-json-isnt-enough">When &ldquo;just return JSON&rdquo; isn&rsquo;t enough</h1>
<p>If you&rsquo;re building a tool that calls an AI API and needs structured data back, at some
point you&rsquo;ll write a prompt that ends with something like: &ldquo;Return a JSON array. No
prose, no explanation, just the JSON.&rdquo;</p>
<p>And most of the time, that works. But &ldquo;most of the time&rdquo; isn&rsquo;t good enough when a parse
error crashes your command mid-run.</p>
<p>I ran into this while building <a href="https://github.com/grymoire7/jojo" target="_blank" rel="noopener noreffer ">Jojo</a>, a CLI
tool that generates job application materials (tailored resumes, cover letters, and
a custom landing page) using AI. Several of its commands need structured data back
from the model: an array of annotations, an array of FAQ entries, an array of integer
indices. Plain text won&rsquo;t do; the output feeds directly into the next stage of the
pipeline.</p>
<h2 id="the-problem-with-prompt-instructions">The problem with prompt instructions</h2>
<p>Language models are trained to be helpful. Part of being helpful, from the model&rsquo;s
perspective, is adding context: explaining what it did, noting any assumptions,
formatting output to be readable. A prompt instruction to &ldquo;return only JSON&rdquo; fights
against that training.</p>
<p>The failure modes are predictable:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><pre tabindex="0"><code>Here is the JSON array you requested:

```json
[{&#34;question&#34;: &#34;What is your experience with Ruby?&#34;, &#34;answer&#34;: &#34;...&#34;}]
```

I&#39;ve formatted this as valid JSON with the required fields.</code></pre></div>
<p>The model followed the spirit of the instruction (it returned valid JSON) but wrapped
it in markdown fences and a helpful introductory sentence. <code>JSON.parse</code> on that string
raises a <code>ParserError</code>.</p>
<p>The original code defended against this with a quick fence-strip:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">cleaned_json</span> <span class="o">=</span> <span class="n">json_string</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\A```(?:json)?\n?/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\n?```\z/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">cleaned_json</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span></span></span></code></pre></div></div>
<p>That handles the fence case. But what about the introductory sentence? What about a model
that adds a trailing note after the JSON? The regex grows, cases multiply, and you end
up with the same defensive code copy-pasted across three different generators.</p>
<h2 id="jsonextractor">JsonExtractor</h2>
<p>Instead, we pull this logic into a single place and make it more robust by
trying multiple strategies to just find the JSON in whatever the model returned.</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">JsonExtractor</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">call</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="kp">false</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">content</span> <span class="k">if</span> <span class="n">content</span><span class="o">.</span><span class="n">is_a?</span><span class="p">(</span><span class="no">Hash</span><span class="p">)</span> <span class="o">||</span> <span class="n">content</span><span class="o">.</span><span class="n">is_a?</span><span class="p">(</span><span class="nb">Array</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">stripped</span> <span class="o">=</span> <span class="n">content</span><span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\A```\w*\s*|\s*```\z/m</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">try_parse</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="n">symbolize_names</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">      <span class="n">try_parse</span><span class="p">(</span><span class="n">stripped</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="n">symbolize_names</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">      <span class="n">extract_first_structure</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="n">symbolize_names</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">      <span class="k">raise</span><span class="p">(</span><span class="no">JSON</span><span class="o">::</span><span class="no">ParserError</span><span class="p">,</span> <span class="s2">&#34;No JSON object found in response&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>It tries three strategies in order:</p>
<ol>
<li>Parse the raw response directly. If the model actually followed instructions, done.</li>
<li>Strip markdown fences and try again. Handles the most common failure mode.</li>
<li>Find the first <code>{</code> or <code>[</code> in the response and parse the complete structure from there,
using a depth-tracking parser to handle nested objects correctly. This handles the
&ldquo;Here is the JSON: [&hellip;]&rdquo; case regardless of what prose surrounds it.</li>
</ol>
<p>Each generator now calls <code>JsonExtractor.call(response)</code> instead of rolling its own
fence-stripping. One test suite covers all the edge cases.</p>
<h2 id="the-cleaner-alternative">The cleaner alternative</h2>
<p>If you&rsquo;re using <a href="https://github.com/crmne/ruby_llm" target="_blank" rel="noopener noreffer ">RubyLLM</a>, a Ruby library that
wraps multiple AI providers behind a common interface, there&rsquo;s a more architecturally
elegant solution: structured output.</p>
<p>Instead of asking the model to &ldquo;return JSON&rdquo; and then parsing whatever it sends back,
you define a schema and ask the model to conform to it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">FaqSchema</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Schema</span>
</span></span><span class="line"><span class="cl">  <span class="n">array</span> <span class="ss">:faqs</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">string</span> <span class="ss">:question</span>
</span></span><span class="line"><span class="cl">    <span class="n">string</span> <span class="ss">:answer</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">with_schema</span><span class="p">(</span><span class="no">FaqSchema</span><span class="p">)</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># response.faqs is an array of objects with .question and .answer</span></span></span></code></pre></div></div>
<p>The model is constrained at the API level, not the prompt level. You get back a parsed
object that already matches your schema. No <code>JSON.parse</code>, no fence-stripping, no
<code>JsonExtractor</code>. The schema also serves as documentation for what the command expects.</p>
<p>This is genuinely cleaner. So why not use it?</p>
<h2 id="the-provider-support-problem">The provider support problem</h2>
<p>Structured output is a feature that providers implement, and not all models
support it. The major providers (Anthropic, OpenAI, Gemini) support it for
their recent models. But older models, like GPT-3.5, Gemini 1.0, Codex, etc.,
don&rsquo;t. Some providers, like Hugging Face, don&rsquo;t support it at all.</p>
<p>Jojo is designed to work with whatever model and provider the user configures. It
dynamically picks up provider credentials from environment variables and routes requests
through RubyLLM. A user might be running Claude Haiku, GPT-4o, or a locally-hosted
model through an OpenAI-compatible endpoint.</p>
<p>RubyLLM&rsquo;s structured output interface is uniform across providers: <code>chat.with_schema</code>
works the same way regardless of which model you&rsquo;re talking to. But when the underlying
model doesn&rsquo;t support structured output, you don&rsquo;t get graceful degradation. You get a
provider error.</p>
<p>That&rsquo;s the thing:</p>
<ul>
<li><code>JsonExtractor</code> works on any string. If the model returns prose with JSON buried in it,
that&rsquo;s handled. If the model returns clean JSON, that&rsquo;s handled. It never throws a
provider error because it never makes a structured output request.</li>
<li>Structured output fails loudly on unsupported models. There&rsquo;s no fallback. You have
to catch the error and decide what to do with it.</li>
</ul>
<p>For a tool where wide model support is a goal, <code>JsonExtractor</code> is more defensive. The
cost is that you&rsquo;re still at the mercy of whatever the model decides to return, but in
practice, every model returns something that contains valid JSON, even if it&rsquo;s surrounded
by other content. The extraction strategy covers the real-world failure modes.</p>
<p>Okay, you also give up strict JSON schema guarantees (if supported) but that&rsquo;s
a tradeoff I&rsquo;m willing to make for broader compatibility.</p>
<h2 id="when-structured-output-is-the-right-call">When structured output is the right call</h2>
<p>None of this is an argument against structured output in general. If you
have good control over exactly which model and provider your code
will run against, structured output is the better choice. The schema and be
explicit, the contract is enforced at the API level, and you&rsquo;re unlikely to
have a parse error at runtime.</p>
<p>You might wonder whether a hybrid approach could get the best of both worlds: check
whether the model supports structured output, use it if so, and fall back to
<code>JsonExtractor</code> if not. RubyLLM does expose this information. Model objects carry a
<code>capabilities</code> array that includes <code>'structured_output'</code> when the feature is supported.
So the check is technically feasible.</p>
<p>The problem is that it doesn&rsquo;t simplify anything. <code>JsonExtractor</code> still has to exist
for the fallback branch, so you haven&rsquo;t reduced maintenance burden. And now you have
two code paths returning different shapes: structured output returns a typed schema
object, while <code>JsonExtractor</code> returns a Hash. Every caller has to handle both, or you
need a normalization layer on top. You&rsquo;ve added complexity without removing any.</p>
<p>The tradeoff is essentially: how much do you want to trade portability for guarantees?
<code>JsonExtractor</code> is portable and handles the real-world messiness of model output.
Structured output gives you stronger guarantees but requires a supported model.</p>
<p>For a tool like Jojo, where users configure their own API credentials and model choices,
<code>JsonExtractor</code> keeps things working across the widest range of setups.
The &ldquo;clean&rdquo; architecture turned out to be less pragmatic once you considered who was
actually going to run it. That person is me, btw, and I want it to work with
whatever model I&rsquo;m testing with at the moment, without having to worry about
structured output support.</p>
]]></description></item><item><title>Building Jojo: turning job applications into marketing campaigns</title><link>https://tracyatteberry.com/posts/jojo/</link><pubDate>Fri, 20 Feb 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/jojo/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/jojo/landing_page.png" referrerpolicy="no-referrer">
            </div><h1 id="building-jojo-turning-job-applications-into-marketing-campaigns">Building Jojo: turning job applications into marketing campaigns</h1>
<p>When you apply for a job, you&rsquo;re competing against hundreds of other candidates.
Most of them submit a resume and a cover letter. The ambitious ones tailor those
documents to the role. And then everyone waits.</p>
<p>No matter how good your resume is, it&rsquo;s still a PDF in a pile of PDFs. You&rsquo;re
asking a hiring manager to do the work of figuring out why you&rsquo;re a fit. What
if you did that work for them?</p>
<p>That&rsquo;s the idea behind <a href="https://github.com/grymoire7/jojo" target="_blank" rel="noopener noreffer ">Jojo</a>, a Ruby CLI I
built to transform job applications into personalized marketing campaigns.
Instead of sending documents, you send a package: a tailored resume, a cover
letter informed by company research, and a dedicated landing page that shows
exactly why you&rsquo;re a match for the role.</p>
<p>The landing page is the centerpiece. It&rsquo;s a mini marketing site with an
annotated job description that maps your experience to their requirements,
portfolio projects selected for relevance to their tech stack, a branding
statement written for their company, LinkedIn recommendations, an FAQ section,
and a call-to-action to schedule a conversation. It turns a passive application
into an active pitch.</p>
<p>Think of it as treating each job application like a product launch. You&rsquo;re the
product. The company you&rsquo;re applying to is the only customer. Jojo builds the
marketing campaign.</p>
<div class="mermaid" id="id-2"></div>
<h2 id="how-it-works">How it works</h2>
<p>The workflow starts with two inputs: your resume data (a structured YAML file)
and a job description (a file or URL). From there, Jojo runs a pipeline of
AI-powered generation steps.</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Create a new application workspace</span>
</span></span><span class="line"><span class="cl">jojo new --slug acme-senior-dev --job posting.txt
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Generate everything</span>
</span></span><span class="line"><span class="cl">jojo generate --slug acme-senior-dev</span></span></code></pre></div></div>
<p>The <code>generate</code> command kicks off a sequence:</p>
<ol>
<li><strong>Research</strong> — AI analyzes the job description and (optionally) searches the
web to build a research document about the company, the role, and how to
position yourself.</li>
<li><strong>Resume</strong> — Your structured resume data is curated and rendered into a
tailored resume, emphasizing the most relevant experience.</li>
<li><strong>Branding</strong> — AI writes a personal branding statement specific to the
company and role.</li>
<li><strong>Cover letter</strong> — Generated from the research and tailored resume, so it
references specific things about the company rather than generic platitudes.</li>
<li><strong>Annotations</strong> — The job description is analyzed requirement by requirement,
with each one mapped to your matching experience.</li>
<li><strong>FAQ</strong> — AI generates role-specific questions and answers based on your
background and the job requirements.</li>
<li><strong>Website</strong> — Everything comes together in a self-contained landing page.</li>
<li><strong>PDF</strong> — Resume and cover letter are converted to PDF via Pandoc.</li>
</ol>
<p>Each step feeds into the next. The research informs the resume tailoring. The
resume informs the cover letter. The annotations and FAQ feed into the website.
It&rsquo;s a pipeline, not a collection of independent scripts.</p>
<p>Every application gets its own workspace directory organized by slug:</p>
<pre>
  applications/acme-senior-dev/
  ├── job_description.md
  ├── job_details.yml
  ├── research.md
  ├── resume.md
  ├── cover_letter.md
  ├── branding.md
  ├── faq.json
  ├── job_description_annotations.json
  ├── status.log
  └── website/
      └── index.html
</pre>
<p>For daily use, there&rsquo;s also an interactive TUI mode. Running <code>jojo</code> with
no arguments launches a dashboard that shows all your applications, tracks which
steps are complete, detects when artifacts are stale (because you regenerated a
dependency), and lets you generate or regenerate individual steps with a
keypress. The staleness detection uses file modification times. If you
regenerate your research, the dashboard knows your resume is now stale because
it was built from the old research.</p>
<pre>
  ┌─ Jojo ────────────────────────────────────────────┐
  │  Active: acme-senior-dev                          │
  │  Company: Acme Corp  •  Role: Senior Developer    │
  ├───────────────────────────────────────────────────┤
  │  Workflow                           Status        │
  │  1. Job Description            $   ✓ Generated    │
  │  2. Research                   $   ✓ Generated    │
  │  3. Resume                     $   * Stale        │
  │  4. Cover Letter               $   ○ Ready        │
  │  ...                                              │
  ├───────────────────────────────────────────────────┤
  │  [1-9] Generate item    [a] All ready    [q] Quit │
  └───────────────────────────────────────────────────┘
</pre>
<p>The <code>$</code> indicator shows which steps call paid APIs, so you know if an action
will cost something before you press the key. Steps that just combine existing
artifacts (like website generation) are free.</p>
<h2 id="architecture-the-command-pipeline">Architecture: the command pipeline</h2>
<p>Jojo is over 5K lines of Ruby source across ~50 source files. Most CLI commands follow
the same three-file pattern:</p>
<pre>
  lib/jojo/commands/{command_name}/
  ├── command.rb    — Orchestration: validates inputs, manages file I/O
  ├── generator.rb  — Content generation: builds context, calls AI
  └── prompt.rb     — AI prompts: system and user prompt templates
</pre>
<p>So when I need to add a new command, I can create these three files, follow the
pattern from the existing commands, and it (hopefully/usually) works. I don&rsquo;t
have to modify a central router or understand the internals of unrelated
commands. The pattern helps make the codebase predictable. If you&rsquo;ve read one
command, you understand the shape of all of them. That helps the human and the
AI assistant.</p>
<p>This wasn&rsquo;t the original architecture. The CLI started as a monolith in
<code>cli.rb</code>. Thor command definitions were mixed with validation logic, file
handling, and generation orchestration. It worked fine for the first few
commands, but soon things got messy. Adding a new feature meant navigating a
growing code heap and hoping your changes didn&rsquo;t break something unrelated.</p>
<p>The refactor extracted each command into its own module with a shared base
class that provides common behavior (slug resolution, config loading, AI client
setup). The CLI file shrank to a thin router with about 150 lines of small
methods that delegate to command classes. Interactive mode, which breifly
had a circular dependency calling back into the CLI class (eww) now calls
command classes directly through a simple adapter.</p>
<h3 id="dual-ai-models">Dual AI models</h3>
<p>Jojo configures two AI models. There&rsquo;s a reasoning model for complex tasks and
a text generation model for simpler ones.</p>
<p>Company research and resume tailoring need the strongest reasoning capabilities
as they&rsquo;re analyzing job requirements, cross-referencing your experience, and
making judgment calls about relevance. But extracting metadata from a job
description (company name, location, job title) is easier. Using a powerful
model for that is like hiring a senior architect to hang shelves.</p>
<p>The reasoning model handles research, resume curation, and cover letter writing.
The text generation model handles job description processing, annotations, FAQ
generation, and branding statements. Both models are configurable per provider,
so you can use a frontier model for reasoning and a faster model for text
generation, or whatever suits your budget and quality needs.</p>
<p>Even with the right model architecture, the AI still has a fundamental
trustworthiness problem when it comes to factual content (welcome to AI).</p>
<h2 id="solving-the-hallucination-problem">Solving the hallucination problem</h2>
<p>This was a technical decision that came from a hard fail.</p>
<p>The original resume generation would take the user&rsquo;s resume data (stored as
structured YAML), combine it with the job description and research, and ask the
AI to generate a tailored resume in markdown. The prompt included extensive
instructions about not fabricating information. It said things like &ldquo;only
include skills the candidate actually has&rdquo; and &ldquo;do not add technologies not
present in the source data.&rdquo;</p>
<p>The AI ignored these instructions way too often. I&rsquo;d review a generated resume
and find &ldquo;Kubernetes&rdquo; listed in my skills because the AI noticed I mentioned
Docker and helpfully inferred I must know Kubernetes too. Or it would embellish
a job description with responsibilities I never had. For a resume, this is not
good.</p>
<p>The first instinct was to add more guardrails to the prompt. More emphatic
instructions. More examples of what not to do. This helped a little, but it
didn&rsquo;t solve the problem. The AI still had the <em>ability</em> to modify anything,
and language-level instructions are suggestions, not constraints.</p>
<h3 id="the-insight-different-fields-have-different-risk-profiles">The insight: different fields have different risk profiles</h3>
<p>A professional summary should be rewritten for each role. That&rsquo;s the whole
point, but a list of programming languages must not be modified. The years you
worked at a company are facts. Your name is your name.</p>
<p>The problem was that &ldquo;AI shouldn&rsquo;t have the same permissions everywhere.&rdquo; Some
fields need smart tailoring. Others need strict preservation. And still others
might be removed or reordered. The idea was to define a permission system
that specifies what the AI can for different kinds of content.</p>
<h3 id="permission-based-curation">Permission-based curation</h3>
<p>The solution was a permission system embedded directly in the resume data:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-yaml">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Bob Denver&#34;</span><span class="w">               </span><span class="c"># default: read-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">email</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;bob@example.com&#34;</span><span class="w">         </span><span class="c"># default: read-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">summary: |                       # permission</span><span class="p">:</span><span class="w"> </span><span class="l">rewrite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">Polyglot developer who enjoys solving problems</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">with software...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">skills:                          # permission</span><span class="p">:</span><span class="w"> </span><span class="l">remove, reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">software engineering</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">full stack development</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">AI assisted development</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">languages:                       # permission</span><span class="p">:</span><span class="w"> </span><span class="l">reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Ruby</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Java</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Python</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Go</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">experience:                      # permission</span><span class="p">:</span><span class="w"> </span><span class="l">reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">company</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Island Adventures Inc.&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">role</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Senior Software Engineer&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">start_date</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2020-07&#34;</span><span class="w">        </span><span class="c"># read-only (nested)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">description: |               # permission</span><span class="p">:</span><span class="w"> </span><span class="l">rewrite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="l">Full-stack developer delivering a SaaS platform...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">technologies:                # permission</span><span class="p">:</span><span class="w"> </span><span class="l">remove, reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Ruby on Rails</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Vue</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Python</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Docker</span></span></span></code></pre></div></div>
<p>Four permission levels:</p>
<ul>
<li><strong>read-only</strong> (default) — AI cannot modify, delete, add, or reorder. Contact
info, dates, company names.</li>
<li><strong>remove</strong> — AI can exclude irrelevant items but can&rsquo;t modify the ones it
keeps. A database skill list can drop SQLite if the role is all PostgreSQL.</li>
<li><strong>reorder</strong> — AI can prioritize by relevance but can&rsquo;t remove or modify. Your
programming languages list stays complete but puts the most relevant ones
first.</li>
<li><strong>rewrite</strong> — AI can generate new content using the original as a factual
baseline. Professional summary, job descriptions.</li>
</ul>
<p>In particular, the AI should never <em>add</em> items that aren&rsquo;t in the source data.
Though there is still a risk of hallucination in rewrite fields, the presence
of original content in smaller chunks provides a grounding that makes it less
likely.</p>
<h3 id="two-pass-pipeline">Two-pass pipeline</h3>
<p>The curation happens in two passes:</p>
<p><strong>Pass 1: Filter and reorder.</strong> The AI receives the full resume data and the
job description. It returns a filtered, reordered version that respects the
permissions on each field. Skills marked <code>remove, reorder</code> get filtered to ~70%
of the most relevant items and sorted by relevance. Lists marked <code>reorder</code> get
sorted but all items are preserved.</p>
<p><strong>Pass 2: Rewrite fields.</strong> The AI receives the filtered data and generates new
content for fields marked <code>rewrite</code>. For example, the professional summary and
experience descriptions. It uses the original content as a factual baseline.</p>
<p>Then an ERB template renders the final markdown. The template handles structure
and formatting. The AI never touches the output templating.</p>
<p>What makes this work as an engineering solution is that the Ruby code
<em>enforces</em> the permissions where possible. If the AI returns a reordered list
that&rsquo;s shorter than the original for a field that only has <code>reorder</code>
permission, the <code>Transformer</code> class raises a <code>PermissionViolation</code> error:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">unless</span> <span class="n">can_remove</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">indices</span><span class="o">.</span><span class="n">length</span> <span class="o">!=</span> <span class="n">original_count</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="no">PermissionViolation</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;LLM removed items from reorder-only field: </span><span class="si">#{</span><span class="n">field_path</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The permissions are no longer buried in  prompt instructions that the AI might
ignore. They&rsquo;re enforced in code. The AI provides <em>suggestions</em> for how to
curate the data, and the Ruby code validates those suggestions against the
permission rules before applying them. If the AI tries to exceed its
permissions, the operation fails rather than silently producing a resume with
fabricated content.</p>
<p>The result is a skills section always contains skills I actually have. My job
dates are always accurate. But my professional summary is tailored for each
role, emphasizing the experience most relevant to that specific position.</p>
<h3 id="what-structured-data-enables">What structured data enables</h3>
<p>In order to make the permission system work, we had to switch from an unstructured
markdown resume to a structured YAML format. This was a significant
architectural change and it required reworking the entire resume generation
pipeline. However, it was necessary to address the hallucination problem.</p>
<p>The permission system is the most visible benefit of using structured data, but
there are other advantages:</p>
<ul>
<li><strong>Narrower AI focus</strong> — With structured data, the AI can focus on curating
specific fields rather than trying to parse and understand a free-form markdown
document. This leads to better quality and more consistent results.</li>
<li><strong>Better output control</strong> — The ERB template handles formatting and
structure, so the AI only generates smaller pieces of content. This reduces
the chances of formatting errors or hallucinated sections and increases the
human control over the final output.</li>
<li><strong>Easier testing</strong> — Structured data is easier to work with in tests. You can
create synthetic resume data with specific permissions and verify that the
output respects those permissions. With unstructured markdown, it&rsquo;s harder to
assert that the AI didn&rsquo;t add or modify content it shouldn&rsquo;t have.</li>
</ul>
<h2 id="testing-as-a-development-discipline">Testing as a development discipline</h2>
<p>A permission system that enforces constraints in code is only trustworthy if
you actually test the enforcement. Jojo has 530 tests across two tiers, with
84% code coverage. Getting there was an intentional investment.</p>
<p>AI coding assistants are enthusiastic about writing features. They&rsquo;re less
enthusiastic about writing tests. This mirrors human tendencies. Tests aren&rsquo;t
as exciting as shipping the next feature, but with AI-assisted development the
gap is amplified.</p>
<p>When the first large refactor was needed I noticed that test coverage was
sitting at 31%. The code worked, but I had no safety net for refactoring. The
push to 84% was a conscious decision to invest in change enablement.</p>
<h3 id="three-kinds-of-tests">Three kinds of tests</h3>
<p>Jojo has three kinds of tests:</p>
<table>
	<thead>
			<tr>
					<th>Kind of test&hellip;</th>
					<th>It tests&hellip;</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Unit tests</td>
					<td>Do small units work?</td>
			</tr>
			<tr>
					<td>Integration tests</td>
					<td>Do small units work together?</td>
			</tr>
			<tr>
					<td>Linting</td>
					<td>Static code analysis</td>
			</tr>
	</tbody>
</table>
<p>All tests run on every <code>./bin/test</code> (or <code>rake test:all</code>) invocation and in CI.</p>
<h3 id="testing-api-dependent-code">Testing API-dependent code</h3>
<p>The trickiest part of testing Jojo is that a lot of interesting work
involves AI and Search API calls. You can&rsquo;t run those in CI without spending
money on every test run, but you also want tests that exercise real response
parsing.</p>
<p>The solution was the VCR gem. VCR records real HTTP interactions the first
time a test runs and saves them as &ldquo;cassettes.&rdquo; On subsequent runs, it replays
the recorded responses instead of making real API calls. You get fast,
deterministic tests that still exercise the full response-parsing pipeline.</p>
<h3 id="fixture-discipline">Fixture discipline</h3>
<p>One rule that has saved me more than once is that tests (and AI) never touch
the <code>inputs/</code> directory — no matter how much AI would like to. That directory
contains real resume data from the user. Tests use <code>test/fixtures/</code>
exclusively, with synthetic data designed for testability.</p>
<p>This is codified in the project&rsquo;s AI guidelines, which was previously prone to
such mistakes. The instructions are explicit, emphatic, and took a few
iterations to be effective. This testing discipline was part of the broader
experience of building with AI.</p>
<h2 id="building-with-ai">Building with AI</h2>
<p>There&rsquo;s a meta quality to this project: it&rsquo;s a tool that uses AI to generate
content, and it was built with AI assistance. Both
<a href="https://claude.ai/code" target="_blank" rel="noopener noreffer ">Claude</a> and <a href="https://z.ai" target="_blank" rel="noopener noreffer ">Z</a> helped with
development.</p>
<p>AI is pretty good at generating boilerplate, brainstorming design alternatives,
and automating the tedious parts of refactoring (like updating 50 files when
you rename a class).</p>
<p>But the decisions this post is about — the curation system, the architecture,
the decision to refactor and when, the test organization — those were human
decisions (as was the choice to use em-dashes just then). AI helped implement
them a bit faster, but it didn&rsquo;t tell me they were needed.</p>
<p>One nice thing about AI-assisted development was the ability to explore
approaches quickly. When I was designing the permission system, I could
describe different architectures, brainstorm, and get working prototypes, all
in fairly short order. That kind of rapid experimentation is really helpful.
The design thinking, however, still has to be yours.</p>
<p>One not-so-nice thing was needing to prod the AI to write tests for the
features it&rsquo;s helping to build. Also, let&rsquo;s be honest, there&rsquo;s a temptation to
let the AI go a little too long before reviewing its output. Left to its own
devices, an AI assistant will happily build feature after feature, with no test
coverage and growing technical debt. Just like a human developer on a deadline,
it needs someone to say &ldquo;we&rsquo;re not adding anything else until we address the
technical debt, and that includes tests.&rdquo;</p>
<h2 id="what-i-learned-and-whats-next">What I learned and what&rsquo;s next</h2>
<p>A few things I&rsquo;d do differently if I started over:</p>
<p><strong>Start with structured data sooner.</strong> The original design used a free-form
markdown resume as input. This was a frightful battle of prompt engineering
from the beginning. The switch to structured YAML data (<code>resume_data.yml</code>) was
the right call, but it required reworking the entire resume generation
pipeline. If I&rsquo;d started with structured data, the permission system would have
been a natural extension rather than a redesign.</p>
<p><strong>Build the interactive mode earlier.</strong> The TUI dashboard made the tool
dramatically more usable, but it came in Phase 6 out of 7. Earlier access to
the dependency graph and staleness detection would have improved my own
workflow during development.</p>
<p><strong>Force TDD from the start, or very near it.</strong> I had a test suite from the
beginning, of course, but it wasn&rsquo;t until I hit a major refactor that I made a
conscious decision to invest in better test coverage. If I had enforced TDD
from the start.</p>
<p>Basically, I would have spent a lot more time up front on planning the
architecture and testing strategy, which would have made the development
process smoother and more maintainable. AI assistance can be great, but
it&rsquo;s also really good at seducing you into bad habits.</p>
<h3 id="whats-next">What&rsquo;s next</h3>
<p>A few potential things for the roadmap:</p>
<ul>
<li><strong>Interview prep generation</strong> — STAR-method examples drawn from your resume
data, tailored to the specific role</li>
<li><strong>More and better theming options</strong> — The landing page is Jojo&rsquo;s UVP, but the
current design is pretty basic. More themes and customization options would let
users create a landing page that better reflects their personal brand.</li>
<li><strong>Application tracking</strong> — Status tracking across all applications with dates,
notes, and follow-up reminders</li>
<li><strong>Full SaaS product</strong> — A Rails app version of Jojo with a user-friendly interface
and full job search management features — this would be a much bigger project
but could help a wider audience.</li>
</ul>
<h3 id="try-it-out">Try it out</h3>
<p>Jojo is open source and available on
<a href="https://github.com/grymoire7/jojo" target="_blank" rel="noopener noreffer ">GitHub</a> with a <a href="https://grymoire7.github.io/jojo/" target="_blank" rel="noopener noreffer ">documentation
site</a>. It&rsquo;s a Ruby CLI that requires AI and
Search provider API keys. Setup takes just a few minutes.</p>
<p>If you&rsquo;re interested in the code, the architecture, or just want to talk about
AI-assisted development, I&rsquo;d enjoy hearing from you. You can find me on
<a href="https://linkedin.com/in/tracyatteberry" target="_blank" rel="noopener noreffer ">LinkedIn</a> or <a href="https://mastodon.social/@grymoire7" target="_blank" rel="noopener noreffer ">Mastodon</a>.</p>
]]></description></item></channel></rss>