<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>All Posts - Tracy Atteberry</title><link>https://tracyatteberry.com/posts/</link><description>All Posts | 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>Thu, 23 Jul 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>All Posts - Tracy Atteberry</title><link>https://tracyatteberry.com/posts/</link></image><atom:link href="https://tracyatteberry.com/posts/" rel="self" type="application/rss+xml"/><item><title>Everything in place</title><link>https://tracyatteberry.com/posts/mise_pitch/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/mise_pitch/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mise_pitch/mise_en_place.jpg" referrerpolicy="no-referrer">
            </div><p>Not long ago <a href="/posts/process_management/" rel="">I wrote</a> about my search
for a process (as in daemons) management tool to manage webservers, build
watch processes, etc. across many different projects. I was still undecided
at that point, but have since landed on <a href="https://pitchfork.jdx.dev" target="_blank" rel="noopener noreffer ">Pitchfork</a>.
It does a bit more than I need and it is well-maintained.
See that previous post for a discussion of that and other options that I
considered.</p>
<p>Having settled on Pitchfork for process management, I next needed to choose
a task runner.
The contenders were: make, just, mise, and go-task/task.
I&rsquo;m not going to do a detailed breakdown, but here&rsquo;s <a href="https://mehdihadeli.com/blog/task-runners-comparison-2026" target="_blank" rel="noopener noreffer ">a post from another
site</a> that does.
It turns out I came to the same decision they did, for mostly the same
reasons. The winner was <a href="https://mise.jdx.dev/" target="_blank" rel="noopener noreffer ">Mise</a>.</p>
<p>You may notice that both tools, Pitchfork and Mise, come from the same
developer. This is a bit of a risk. If the developer (Jeff Dickey) decides
they&rsquo;re no longer interested in maintaining these projects, then I&rsquo;m
suddenly looking for two tool replacements instead of one. But right now,
looking at Jeff&rsquo;s bio, income from his sponsors, support from his employer,
etc., I&rsquo;m really not too concerned.</p>
<p>I picked the top 19 most active repos that I have and migrated all of them
to Pitchfork and Mise. Having that consistency across repos has made
context switching much easier. It&rsquo;s been great so far.</p>
<p>Around the same time, I also began using a few new
<a href="https://github.com/grymoire7/skills" target="_blank" rel="noopener noreffer ">skills</a>. In addition to Pitchfork and
Mise skills, I added a markdown formatter and my own writing skill. The
writing skill comes with an LLM cliche linter tool as well. During the same
migration, I ran the markdown formatter over all my READMEs, so now they&rsquo;re
easier for me to scan and find things in too.</p>
<p>I&rsquo;ll add just a bit of brutal honesty here at the end in case you made it this
far. Whenever I have a little extra time, or I&rsquo;m just procrastinating on
something, I tend to either a) tidy up, or b) try to improve the process.
That was exactly the inspiration for this migration. Cheers!</p>
]]></description></item><item><title>Reviewing project process management tools</title><link>https://tracyatteberry.com/posts/process_management/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/process_management/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/process_management/watermelon_pitchfork.jpg" referrerpolicy="no-referrer">
            </div><h2 id="the-inertia-problem">The inertia problem</h2>
<p>When I start working on a project, I open a terminal and start a few processes.
The web server. A background job worker. The asset watcher that recompiles my
JavaScript. Maybe a locally-running database service. Each one might get its
own terminal tab, and each tab has a command I have to remember, or dig out of
a README, or other docs.</p>
<p>Since I work across multiple projects in different languages, with different
stacks, this gets expensive fast. Not expensive in terms of CPU or memory, but
in terms of mental overhead. Every project has its own conventions: <code>make dev</code>,
<code>./bin/dev</code>, <code>docker compose up</code>, or something else entirely. You spend more
time than you&rsquo;d like just overcoming the inertia to get back to a running state
every time you switch projects.</p>
<p>A process manager gives you a single command to start everything your project
needs. And if you can standardize on one tool across all your projects (🤞), you
eliminate that context-switching tax entirely: <code>cd project &amp;&amp; [tool] start</code>
works everywhere, every time.</p>
<p>That&rsquo;s the goal, so let&rsquo;s look at the options. (Or just scroll down to the
comparison table and the verdict if you want to skip the narrative.)</p>
<p>But before I dive in, it&rsquo;s worth being clear about what I&rsquo;m trying to solve for
here. Most frameworks give you ways to run individual commands natively: <code>npm run dev</code>, <code>mix phx.server</code>, <code>bundle exec rails server</code>. Those are great and you
should use them. Your Procfile entries will often call them directly. A process
manager sits on top of those commands and coordinates them, starting all your
services at once and multiplexing their output into a single view. The two
approaches are complementary, not competing.</p>
<p>There&rsquo;s also a related but separate problem. Task runners like Make, Rake,
Just, and npm scripts handle short-lived commands like running database
migrations, executing tests, or triggering builds. You will still reach for
those tools for that kind of work, and there&rsquo;s a whole separate conversation to
be had about achieving consistency there across projects. This post is focused
specifically on the process management side: starting and keeping multiple
long-lived development services running together.</p>
<h2 id="it-all-started-with-a-procfile">It all started with a Procfile</h2>
<p>In 2011, David Dollar released <a href="https://github.com/ddollar/foreman" target="_blank" rel="noopener noreffer ">Foreman</a>, a Ruby gem for
managing multiple processes during development. The central idea was the <code>Procfile</code>: a plain text
file that declares your process types, one per line.</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>web: bin/rails server
worker: bundle exec sidekiq
assets: npm run watch</code></pre></div>
<p>Heroku adopted the format and popularized it. Today it&rsquo;s the closest thing the development world
has to a standard for declaring process types. Ports of Foreman exist for Python
(<a href="https://github.com/nickstenning/honcho" target="_blank" rel="noopener noreffer ">Honcho</a>), Go
(<a href="https://github.com/mattn/goreman" target="_blank" rel="noopener noreffer ">goreman</a>, <a href="https://github.com/ddollar/forego" target="_blank" rel="noopener noreffer ">forego</a>), and
more. Those ports largely replicate the original feature set, so I won&rsquo;t review them separately,
but the ecosystem shows how durable the Procfile idea has been.</p>
<p>What followed were tools that kept the Procfile format but went well beyond what Foreman
originally offered. Here&rsquo;s where things stand today.</p>
<h2 id="the-tools">The tools</h2>
<h3 id="foreman">Foreman</h3>
<p>Foreman is where it all started for me, and it still does its job. Install the gem, drop a Procfile
in your project, and run <code>foreman start</code>. It multiplexes the output of all your processes into a
single stream, prefixed with the process name and a timestamp.</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 foreman
</span></span><span class="line"><span class="cl">foreman start</span></span></code></pre></div></div>
<p>One unique feature is that Foreman can export your Procfile to init system formats like
systemd, upstart, launchd, and runit. If you want to manage production processes the same way
you manage development ones, that export path is a nice convenience. None of the other tools
in this review offer it.</p>
<p><strong>Pros:</strong> Mature and battle-tested. Dead simple to adopt. Procfile export to init systems is
unique among these tools. Wide adoption means good documentation and community support.</p>
<p><strong>Cons:</strong> It&rsquo;s a Ruby gem, which adds a runtime dependency for non-Ruby projects. The bigger
issue is how it handles process output. Processes detect they are not writing to a real terminal
and suppress their colored output. If you&rsquo;ve ever run <code>rails server</code> through Foreman and wondered
why the logs look flat and grey, that&rsquo;s why.</p>
<hr>
<h3 id="hivemind">Hivemind</h3>
<p><a href="https://github.com/DarthSim/hivemind" target="_blank" rel="noopener noreffer ">Hivemind</a> was built by the team at Evil Martians to fix
exactly that problem. It uses a PTY (pseudo-terminal) to capture process output, which means
processes think they are writing to a real terminal. So colors work and log output behaves the way
you&rsquo;d expect.</p>
<p>It&rsquo;s a standalone Go binary with no runtime dependencies beyond the binary itself. Install it
with Homebrew or download the binary directly.</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">brew install hivemind
</span></span><span class="line"><span class="cl">hivemind</span></span></code></pre></div></div>
<p>Hivemind is deliberately minimal. It reads your Procfile, starts your processes, and gets out of
the way. The README itself will tell you: if you want more features, check out Overmind.</p>
<p><strong>Pros:</strong> Standalone binary with no language runtime required. Fixes the color output problem.
Extremely easy to adopt, with the same Procfile format and simpler installation than Foreman for
non-Ruby projects.</p>
<p><strong>Cons:</strong> No interactive process management. If a process crashes, the whole group goes down.
There&rsquo;s no way to restart a single process without restarting everything.</p>
<hr>
<h3 id="overmind">Overmind</h3>
<p><a href="https://github.com/DarthSim/overmind" target="_blank" rel="noopener noreffer ">Overmind</a> is Hivemind&rsquo;s more capable sibling, also from
Evil Martians. The key difference is that Overmind runs your processes inside a tmux session,
which gives it capabilities no other Procfile tool matches.</p>
<p>You can connect directly to any process and interact with it:</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">overmind connect web</span></span></code></pre></div></div>
<p>You can restart a single process without touching the others:</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">overmind restart sidekiq</span></span></code></pre></div></div>
<p>You can tell Overmind that certain processes are allowed to exit without bringing everything else
down, which is useful for one-time setup tasks like database migrations. Auto-restart on crash is
configurable per process, and port assignment is automatic and consistent so you can reference
one process&rsquo;s port from another&rsquo;s configuration.</p>
<p>The tmux integration turns out to be more than a dependency. If you&rsquo;ve ever wanted to connect to
a running Rails process to use the console, or poke at a webpack dev server that&rsquo;s behaving
strangely, <code>overmind connect</code> gets you there without restarting anything.</p>
<p><strong>Pros:</strong> Full PTY support, so colors work. Individual process control without affecting the
group. Auto-restart on crash. Port management per process. Highly configurable via environment
variables so your preferences apply globally across every project.</p>
<p><strong>Cons:</strong> Requires tmux. If tmux isn&rsquo;t already part of your workflow, this is a real dependency,
not just a binary download. Nested tmux sessions are manageable if you&rsquo;re already inside tmux,
but worth knowing about upfront.</p>
<hr>
<h3 id="overitall">Overitall</h3>
<p><a href="https://github.com/aha-app/overitall" target="_blank" rel="noopener noreffer ">Overitall</a> (<code>oit</code>) takes a different angle entirely.
Where the previous tools focus on running processes and multiplexing their output, Overitall
treats logs as a first-class concern. It&rsquo;s a TUI (terminal user interface) that combines process
management with what amounts to a built-in log viewer.</p>
<p>You still use a Procfile for defining processes, but a <code>.overitall.toml</code> config file can also
point at standalone log files rather than just process output. This is useful for services that
write to a file instead of stdout, or for situations where you want to correlate a running
process&rsquo;s output with a framework&rsquo;s own log file.</p>
<p>The TUI gives you real-time search (regex-capable), per-process visibility toggles, time-based
navigation (<code>:goto -5m</code>), and trace detection that can spot correlation IDs and UUIDs in your
logs and filter to a specific trace. For log-heavy development workflows, this is a meaningful
upgrade over watching interleaved output scroll by.</p>
<p>There&rsquo;s also an AI integration story: run <code>oit skill install</code> and Overitall installs a skill into
<code>.claude/skills/oit/</code> (or the Cursor equivalent) that teaches your AI assistant how to control
the running TUI from the command line. You can then ask Claude to check recent errors, restart a
process, or search the logs without leaving your editor.</p>
<p><strong>Pros:</strong> Log management as a first-class feature. Regex filtering, time navigation, and trace
detection. Interactive TUI with per-process visibility control. AI skill for Claude Code and
Cursor. Available on both macOS and Linux.</p>
<p><strong>Cons:</strong> Requires an additional <code>.overitall.toml</code> config file alongside the Procfile. The TUI
has a learning curve compared to a simple process runner. It&rsquo;s a newer project and still
maturing.</p>
<hr>
<h3 id="pitchfork">Pitchfork</h3>
<p><a href="https://pitchfork.jdx.dev" target="_blank" rel="noopener noreffer ">Pitchfork</a> is the most ambitious tool in this list, and also the
most different. Where everything else runs your processes in the foreground and stops them when
you close the terminal, Pitchfork manages daemons: background processes that keep running after
you walk away. A supervisor process handles them, and you interact with it via a CLI.</p>
<p>The configuration is a <code>pitchfork.toml</code> file:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-toml">
        <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-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">daemons</span><span class="p">.</span><span class="nx">redis</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">run</span> <span class="p">=</span> <span class="s2">&#34;redis-server --port 6379&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">auto</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;start&#34;</span><span class="p">,</span> <span class="s2">&#34;stop&#34;</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 class="nx">daemons</span><span class="p">.</span><span class="nx">api</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">run</span> <span class="p">=</span> <span class="s2">&#34;npm run dev&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">auto</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;start&#34;</span><span class="p">,</span> <span class="s2">&#34;stop&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">depends</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;redis&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">ready_http</span> <span class="p">=</span> <span class="s2">&#34;http://localhost:3000/health&#34;</span></span></span></code></pre></div></div>
<p>The <code>auto = [&quot;start&quot;, &quot;stop&quot;]</code> setting is the most interesting feature for the cross-project
consistency goal. With shell hooks activated, Pitchfork starts those daemons automatically when
you <code>cd</code> into the project directory and stops them when you leave. You never have to remember
to start your services. You just navigate to your project and they&rsquo;re there.</p>
<p>The feature list goes further: dependency ordering (so Redis starts before the API that needs
it), ready checks (so Pitchfork waits until a health endpoint actually responds before declaring
a service up), file watching for auto-restart, cron scheduling, resource limits, and a web UI.
Pitchfork also ships a built-in MCP server. One JSON config block in your Claude Desktop or
Cursor settings wires your AI assistant directly into daemon management.</p>
<p>Color output is available but requires opting in with <code>pty = true</code> per daemon. Since processes
run in the background by default, PTY is not on automatically, but enabling it is a one-line
change per daemon.</p>
<p><strong>Pros:</strong> The most powerful feature set in this review by a wide margin. Auto-start and auto-stop
via shell hooks directly address the cross-project consistency problem. Built-in MCP server for
AI integration. Dependency ordering, ready checks, and cron scheduling included. Language-agnostic
standalone binary.</p>
<p><strong>Cons:</strong> Steeper learning curve and a new configuration format to learn. The daemon model is a
different mental model than a foreground process runner. You check on services rather than
watching them scroll past. Color output requires explicit opt-in per daemon. Actively developed
and not yet at 1.0.</p>
<hr>
<h2 id="feature-comparison">Feature comparison</h2>
<table>
	<thead>
			<tr>
					<th>Feature</th>
					<th>Foreman</th>
					<th>Hivemind</th>
					<th>Overmind</th>
					<th>Overitall</th>
					<th>Pitchfork</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Config format</td>
					<td>Procfile</td>
					<td>Procfile</td>
					<td>Procfile</td>
					<td>Procfile + .overitall.toml</td>
					<td>pitchfork.toml</td>
			</tr>
			<tr>
					<td>Install</td>
					<td>Ruby gem</td>
					<td>Binary</td>
					<td>Binary</td>
					<td>Binary</td>
					<td>Binary</td>
			</tr>
			<tr>
					<td>Preserves colors (PTY)</td>
					<td>❌</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>Opt-in per daemon</td>
			</tr>
			<tr>
					<td>Interactive TUI</td>
					<td>❌</td>
					<td>❌</td>
					<td>via tmux</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>tmux integration</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>❌</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>Connect to process</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>❌</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>Restart individual process</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Process scaling (formation)</td>
					<td>✅</td>
					<td>❌</td>
					<td>✅</td>
					<td>❌</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>Auto-restart on crash</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Dependency ordering</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Daemon mode</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Shell auto-start hooks</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Ready checks</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Advanced log viewing</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>basic</td>
			</tr>
			<tr>
					<td>Export to init systems</td>
					<td>✅</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>macOS support</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Linux support</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>AI integration</td>
					<td>&ndash;help / man page</td>
					<td>&ndash;help</td>
					<td>&ndash;help</td>
					<td>Claude/Cursor skill</td>
					<td>Built-in MCP server</td>
			</tr>
	</tbody>
</table>
<h2 id="which-tool-is-right-for-you">Which tool is right for you?</h2>
<p><strong>Choose Foreman</strong> if your project is already Ruby-based and you want the simplest possible
setup. The gem installs alongside your other dependencies, and the ecosystem familiarity is hard
to beat. It&rsquo;s also the right call if you need to export process definitions to init system
formats for production use, since no other tool here does that.</p>
<p><strong>Choose Hivemind</strong> if you want a no-frills Procfile runner that works for any
project in any language, with proper color output. It&rsquo;s an easy, zero-cost,
drop-in replacement for Foreman for those that don&rsquo;t need individual process
control.</p>
<p><strong>Choose Overmind</strong> if you live in tmux and want the ability to connect to, inspect, and restart
individual processes without tearing down your whole stack. Once you&rsquo;ve used <code>overmind connect</code>
to drop into a running process&rsquo;s window, it&rsquo;s probably hard to let that go.</p>
<p><strong>Choose Overitall</strong> if log management is a first-class concern in your
workflow. Noisy logs from multiple services, distributed traces to chase,
correlation IDs to filter on: this is where <code>oit</code>&rsquo;s search, filtering, and time
navigation start to pay off. I can see this being really useful in a more
complex $work environment. The AI skill integration is also a practical bonus
if you use Claude Code or Cursor heavily.</p>
<p><strong>Choose Pitchfork</strong> if you want services that follow you between projects and
start automatically without any deliberate action on your part. The daemon
model requires a mental shift, but it has a nice TUI (and Web UI) and it&rsquo;s the
most direct answer to the cross-project consistency problem in this roundup.</p>
<h2 id="the-verdict">The verdict</h2>
<p>If you&rsquo;re looking for a single tool to carry across every project regardless of language or
stack, the choice comes down to how much you want to invest in configuration upfront versus how
much friction you want to eliminate long-term.</p>
<p>For a lightweight universal runner, <strong>Hivemind</strong> is the pragmatic choice. Single binary, no
runtime dependencies, correct color output, zero learning curve. Drop a Procfile in a project and
you&rsquo;re running. The limitation of no individual process control rarely matters until it suddenly
does, and when it does, you know where to go next.</p>
<p>If you are already a tmux user, <strong>Overmind</strong> is the easy upgrade over Hivemind. Same binary
story, same Procfile format, but you gain individual process control and the ability to interact
directly with any running process. The additional capability costs almost nothing if tmux is
already in your toolkit.</p>
<p>The most interesting answer for the cross-project consistency problem, though,
is <strong>Pitchfork</strong>. Shell hooks that auto-start and auto-stop services as you
move between directories solve the problem at a deeper level than any
foreground runner can. You stop managing process startup entirely, and your
services are just there when you need them. That comes with more upfront
configuration and a different operational model, but if you work across many
projects and that investment appeals to you, Pitchfork is the tool that most
ambitiously reframes what a development process manager can be.</p>
<p>For myself, I&rsquo;m honestly not sure where I&rsquo;ll land quite yet. I have a soft spot
for the simplicity of Hivemind, I&rsquo;m not a frequent tmux user so Overmind&rsquo;s
advantages are less compelling, and Pitchfork&rsquo;s daemon model is intriguing but
requires extra work for me and my AI integration.</p>
]]></description></item><item><title>Documentation next: Habits that survive AI-assisted development</title><link>https://tracyatteberry.com/posts/documentation_next/</link><pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/documentation_next/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/documentation_next/hero.jpg" referrerpolicy="no-referrer">
            </div><p>AI lets you ship faster than ever. That&rsquo;s great. But six months later, you&rsquo;re
staring at your own codebase wondering who wrote this. Why is this service
doing that? Why did you pick this library over the obvious one? What were you
thinking?</p>
<p>The problem usually isn&rsquo;t the code. It&rsquo;s that AI accelerates the building
without capturing the thinking. You evaluate options, make tradeoffs, reject
approaches, and move on. The decision happens in a chat window and then it&rsquo;s
gone. What&rsquo;s left is code without context, and that debt (a form of cognitive
debt) compounds quietly until it becomes a real problem.</p>
<p>Though it might help, the best fix isn&rsquo;t to slow down. It&rsquo;s to build a few
lightweight documentation habits that will hold up in an AI-assisted workflow.</p>
<h2 id="the-new-documentation-problem">The new documentation problem</h2>
<p>Traditional documentation advice assumes you understand everything you&rsquo;re
writing. You wrote the code, you know why it works the way it does, you just
need to write it down. AI-assisted development changes that assumption in a
couple of ways.</p>
<p>First, you move faster. When you&rsquo;re evaluating three AI-generated
implementations in ten minutes instead of writing one over an hour, there&rsquo;s
almost no natural pause where documentation feels like the obvious next step.
The decision-making is compressed, and so is the documentation.</p>
<p>Second, git history gets less useful. It still tells you what changed, but it
doesn&rsquo;t tell you that you picked this background job library because the other
one had a deployment constraint you needed to avoid. It doesn&rsquo;t tell you which
of four Stripe webhook implementations the AI suggested, or why you picked that
one. It doesn&rsquo;t tell you that you almost went a completely different direction.</p>
<p>The gap is context. And that&rsquo;s exactly what these habits are designed to
capture.</p>
<h2 id="document-decisions-not-implementations">Document decisions, not implementations</h2>
<p>The highest-leverage documentation habit is writing down why, not what. Code
already shows what. What it can&rsquo;t show is the thinking that produced it.</p>
<p>Keep a <code>docs/decisions.md</code> file. No formal template required. No ADR ceremony.
Just a running list of decisions, what options you considered, what you ruled
out and why, and what you landed on. A few sentences per decision is enough.</p>
<p>Some examples of the kind of thing worth capturing:</p>
<ul>
<li>Why you chose Sidekiq over Solid Queue (maybe it was a deployment constraint,
maybe it was familiarity, maybe you tried Solid Queue and hit a weird edge case)</li>
<li>Which Stripe webhook implementation you kept after the AI suggested three
different approaches</li>
<li>Why the data model is shaped the way it is instead of the more obvious
structure you considered first</li>
</ul>
<p>This is especially useful in AI-assisted work because the evaluation often
happens fast. You look at a few options, pick one, and move on. Without a
quick note somewhere, that reasoning is gone the moment you close the chat.</p>
<h2 id="the-daily-scratchpad">The daily scratchpad</h2>
<p>A scratchpad is not a journal. It&rsquo;s not a changelog. It&rsquo;s a place to think out
loud while you&rsquo;re working.</p>
<p>Drop a file at <code>tmp/scratch.md</code> (some people call it <code>journal.md</code> or
<code>dev-journal.md</code>) and use it to capture rationale in the moment. What did you
try? What didn&rsquo;t work? How did you arrive at this solution? What were you
unsure about?</p>
<p>It doesn&rsquo;t need to be clean. It doesn&rsquo;t need to be organized. It just needs to
exist.</p>
<p>Git history will tell future you that something changed on a Tuesday in March.
The scratchpad will tell future you that you were choosing between two
approaches, that one of them had a subtle concurrency problem you figured out
mid-afternoon, and that the solution you landed on was a compromise you felt
okay about. That&rsquo;s the stuff that can really help when you return to a project
three months later and can&rsquo;t quite remember what you were even trying to do.</p>
<h2 id="let-ai-explain-what-it-built">Let AI explain what it built</h2>
<p>Sometimes you ship code you don&rsquo;t fully understand yet. That&rsquo;s not a great
position to be in, but it&rsquo;s a real one, especially when you&rsquo;re learning a new
language or framework alongside building something.</p>
<p>When that happens, ask the AI to explain what it just built.</p>
<p>Recently I was building a new SaaS product in Elixir while still actively
learning the language. The initial architecture involved OTP supervision trees,
GenServers, and some patterns that weren&rsquo;t immediately obvious (to me). Rather
than hoping the understanding would come later, asking the AI to write an
<code>explainer.md</code> for the architecture made the whole thing legible. Later, an
<code>explainer.html</code> with diagrams and a walkthrough made it even better.</p>
<p>You can do the same thing with an existing codebase you&rsquo;re jumping into for the
first time. Drop the relevant files into context and ask for an explainer. It&rsquo;s
faster than reverse-engineering the code yourself, and it gives you something
you can update and refer back to as your understanding deepens.</p>
<p>My explainers live in <code>docs/explainers/</code>. They&rsquo;re not precious documents. They
can be rough, they can be wrong in places, and they should be updated as you
learn more. But having them at all is the difference between a codebase that
feels like yours and one that always feels a little foreign.</p>
<h2 id="keep-your-readme-human">Keep your README human</h2>
<p>AI tooling has introduced a temptation to stuff READMEs with prompts, agent
instructions, and notes about which parts were AI-generated. Resist it.</p>
<p>The README is for humans. What is this project? How do I get it running? Where
is the rest of the documentation? How do I contribute?</p>
<p>AI-specific context belongs elsewhere. Agent instructions and project rules for
your AI assistant go in <code>AGENTS.md</code> (if you use Claude, a <code>CLAUDE.md</code> symlink
pointing to it is handy). Plans and specs go in <code>docs/plans/</code> and
<code>docs/specs/</code>. Decisions go in <code>docs/decisions.md</code>. Explainers go in
<code>docs/explainers/</code>.</p>
<p>The README is the front door. Keep it welcoming and keep it human.</p>
<h2 id="own-the-code-all-of-it">Own the code, all of it</h2>
<p>You don&rsquo;t need to mark which lines an AI wrote.</p>
<p>I know that might feel counterintuitive. There&rsquo;s a reasonable instinct to be
transparent about your process. But there&rsquo;s a difference between transparency
and hedging. Marking code as AI-generated is a subtle way of distancing
yourself from it, as if to say: if this breaks, that&rsquo;s not entirely on me. But
that&rsquo;s not how accountability works.</p>
<p>You are responsible for every line of code you ship, regardless of how it was
produced. If you don&rsquo;t understand the code, you shouldn&rsquo;t ship it. If you&rsquo;re
not sure you understand it, that&rsquo;s what explainers are for.</p>
<p>The middle ground is being transparent about your development process at a
high level. It&rsquo;s no secret that most developers use AI assistance now, and
saying so in your docs or your README is great. That&rsquo;s transparency. What&rsquo;s
different is using it as a hedge. The tool you used to write the code doesn&rsquo;t
change your responsibility for the result.</p>
<h2 id="the-loop">The loop</h2>
<p>Good documentation helps you understand your codebase. Understanding your
codebase means you can give the AI better context when you&rsquo;re building something
new. Better context (human and AI) produces code that really fits the existing
architecture. Code that fits is easier to explain and document. Round and
round.</p>
<p>None of these habits are heavy. A few sentences in <code>decisions.md</code>. A rough note
in <code>scratch.md</code>. An explainer file when you&rsquo;re in unfamiliar territory. A
README that stays focused on humans.</p>
<p>The payoff is that six months from now, when you come back to this project, it
feels like yours. You remember what you were thinking. You know why things are
the way they are. And you can keep building without spending the first week
just figuring out what past-you was up to.</p>
<h2 id="bonus-automate-it-with-a-post-merge-hook">Bonus: automate it with a post-merge hook</h2>
<p>All of these habits share a common enemy: you have to remember to do them.
Remembering is fine when you&rsquo;re focused, but documentation is usually the thing
that slips when you&rsquo;re rushing to ship.</p>
<p>Here&rsquo;s a way to take the remembering out of it. A git <code>post-merge</code> hook fires
automatically after every merge. You can use it to shell out to Claude Code in
non-interactive mode and ask it to review and update your docs in light of what
just changed.</p>
<p>The hook grabs the merge diff, hands it to Claude along with a focused prompt,
and lets Claude update <code>README.md</code>, <code>docs/decisions.md</code>, any relevant explainers,
and product docs. Changes are left unstaged so you can review them before
committing. Nothing gets force-pushed. Nothing gets auto-committed. You stay
in control.</p>
<p>Here&rsquo;s the script:</p>
<div class="code-block code-line-numbers" 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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># .git/hooks/post-merge</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># After merging to main, asks Claude Code to review and update documentation.</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Changes are left unstaged for your review.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Setup:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   cp post-merge .git/hooks/post-merge</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   chmod +x .git/hooks/post-merge</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Requires Claude Code CLI to be installed and authenticated.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Only run on main branch</span>
</span></span><span class="line"><span class="cl"><span class="nv">CURRENT_BRANCH</span><span class="o">=</span><span class="k">$(</span>git symbolic-ref --short HEAD<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$CURRENT_BRANCH</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;main&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;📝 Checking documentation after merge to main...&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Capture what changed in the merge so Claude has context</span>
</span></span><span class="line"><span class="cl"><span class="nv">DIFF</span><span class="o">=</span><span class="k">$(</span>git diff ORIG_HEAD..HEAD --stat<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build the prompt</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROMPT</span><span class="o">=</span><span class="s2">&#34;A feature branch was just merged to main. Here is a summary of what changed:
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="nv">$DIFF</span><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Please review the following documentation and update anything that is out of date
</span></span></span><span class="line"><span class="cl"><span class="s2">or missing given the changes above:
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">- README.md
</span></span></span><span class="line"><span class="cl"><span class="s2">- docs/decisions.md (add an entry if a significant architectural decision was made)
</span></span></span><span class="line"><span class="cl"><span class="s2">- Any relevant files in docs/explainers/
</span></span></span><span class="line"><span class="cl"><span class="s2">- Any relevant product documentation in docs/
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">Do not modify AGENTS.md or any files in docs/plans/ or docs/specs/.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">After making changes, print a brief summary of what you updated and why.
</span></span></span><span class="line"><span class="cl"><span class="s2">If nothing needed updating, say so.&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run Claude Code in non-interactive mode with read/write permissions only</span>
</span></span><span class="line"><span class="cl">claude -p <span class="s2">&#34;</span><span class="nv">$PROMPT</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --allowedTools <span class="s2">&#34;Read&#34;</span> <span class="s2">&#34;Write&#34;</span> <span class="s2">&#34;Glob&#34;</span> <span class="s2">&#34;Grep&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --max-turns <span class="m">10</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Show which files were changed (if any)</span>
</span></span><span class="line"><span class="cl"><span class="nv">DOC_CHANGES</span><span class="o">=</span><span class="k">$(</span>git diff --name-only -- README.md <span class="s1">&#39;docs/**&#39;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$DOC_CHANGES</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;✅ Documentation updated. Review changes before committing:&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$DOC_CHANGES</span><span class="s2">&#34;</span> <span class="p">|</span> sed <span class="s1">&#39;s/^/   /&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;   Run &#39;git diff&#39; to review, then &#39;git add&#39; and &#39;git commit&#39; when ready.&#34;</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">echo</span> <span class="s2">&#34;✅ No documentation changes were necessary.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div>
<p>Download here: <a href="./post-merge" rel="">post-merge</a></p>
<p>To install it, copy the script to <code>.git/hooks/post-merge</code> and make it
executable with <code>chmod +x .git/hooks/post-merge</code>. Since <code>.git/</code> isn&rsquo;t tracked
by git, you might want to keep the canonical copy in a <code>hooks/</code> directory in
your repo and add a setup step to your README.</p>
<p>A few things worth noting about the script:</p>
<p>The <code>--allowedTools</code> flag scopes Claude to read and write only. It can&rsquo;t run
arbitrary bash commands, can&rsquo;t touch your git history, and can&rsquo;t do anything
outside the files you&rsquo;ve pointed it at. Keeping the tool surface small is good
hygiene for any automated Claude Code invocation.</p>
<p>The <code>--max-turns 10</code> flag is a circuit breaker. It caps how many agentic turns
Claude can take so a single post-merge hook can&rsquo;t spin forever if something
unexpected happens.</p>
<p>The prompt explicitly excludes <code>AGENTS.md</code>, <code>docs/plans/</code>, and <code>docs/specs/</code>.
You don&rsquo;t want a merge hook rewriting your agent instructions or your design
plans. Keep the scope tight.</p>
<p>The <code>--max-turns</code> flag is a circuit breaker, but may need to be tuned based on
how much the amount of work that needs to be done. If the circuit breaker is
hit, the hook will exit with an error and leave the finished changes unstaged
for manual review.</p>
<p>This is a starting point, not a finished product. Your project&rsquo;s doc structure
will differ, and you&rsquo;ll want to tune the prompt to match. But the pattern holds:
capture the diff, give Claude focused scope, leave changes unstaged, and let the
human decide what to commit.</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>Cognitive debt: A personal story and practical advice</title><link>https://tracyatteberry.com/posts/cognitive_debt/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/cognitive_debt/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/cognitive_debt/cognitive_debt.png" referrerpolicy="no-referrer">
            </div><p>A few days ago I built a tool called JoyConf: a real-time audience feedback
system that lets speakers see emoji reactions floating up in the corner of
their presentation while they&rsquo;re talking. It was a fun, simple idea and I was
pretty excited about it.</p>
<p>I built it in Elixir and Phoenix LiveView, which was a deliberate choice. I
mostly write Ruby these days, but this project felt like a good excuse to dig
into Elixir and LiveView. Learn something new, build something useful. Two birds,
one stone.</p>
<p>I drove the overall design and implementation planning, did active code review,
and contributed everywhere I could. But for the Elixir and LiveView specifics,
I leaned heavily on Claude. The syntax, architecture decisions, and debugging
were Claude&rsquo;s domain, because I simply didn&rsquo;t know enough yet to own them. The
tool worked and it <em>seemed</em> to work well. But when I got to the end and looked at
the codebase, I realized I didn&rsquo;t really understand the parts Claude had built.
I had reviewed the code as carefully as I could, but reviewing code in a language
you don&rsquo;t know, implementing an unfamiliar architecture, only gets you so far.
The understanding of those pieces had mostly stayed with Claude.</p>
<p>That&rsquo;s cognitive debt. And LLMs are very good at generating it.</p>
<h2 id="what-cognitive-debt-actually-is">What cognitive debt actually is</h2>
<p>Cognitive debt accumulates when you defer the thinking that should happen now.
It&rsquo;s different from technical debt, which is about the code itself (shortcuts
taken, tests skipped, abstractions that didn&rsquo;t quite work out). Cognitive debt is
about the reasoning that never happened. The mental model that never got built.
The decision that got made without being understood.</p>
<p>Like financial debt, it doesn&rsquo;t feel like much at first. You&rsquo;re moving fast,
things are working, you&rsquo;re shipping. The bill comes later, when you need to
debug something you can&rsquo;t reason about, extend a system you don&rsquo;t understand,
or explain a decision you never actually made. And to be clear, cognitive debt
has been around long before LLMs, LLMs just magnify the problem.</p>
<h2 id="llms-make-this-disturbingly-easy">LLMs make this disturbingly easy</h2>
<p>LLM-generated code is mostly right. That&rsquo;s what makes it dangerous.</p>
<p>If the code were obviously wrong, you&rsquo;d catch it. You&rsquo;d dig in, figure out what
went wrong, learn something in the process. But LLM output is usually plausible,
often correct, and just coherent enough that it passes the vibe check. You run
the tests. They pass. You move on. The mental model of how it works never gets
built, because you never needed it&hellip; until you do.</p>
<p>There&rsquo;s a specific failure mode worth naming here. Using an LLM to move faster
on things you understand is <em>leverage</em>. Using it to skip understanding altogether
is <em>debt</em>. Those feel identical in the short term. Both result in code getting
written. One leaves you with understanding you can build on; the other leaves you
with output you&rsquo;re stuck with.</p>
<p>And it catches everyone. Junior developers accept LLM output because they don&rsquo;t
know enough to question it. Senior developers accept it because they had a
hundred PRs today and the code looks fine, so they assume it is fine. Both skip
the reasoning step. The result is a codebase full of decisions nobody on the
team can actually defend.</p>
<h2 id="back-to-joyconf">Back to JoyConf</h2>
<p>When I realized I&rsquo;d built something I didn&rsquo;t fully understand, I asked Claude to
write me an explainer document. Not a summary, but an actual explanation of the
architecture, the key concepts, why certain decisions were made, how the pieces
fit together. Something I could read, learn from, and come back to later.</p>
<p>It wasn&rsquo;t a magic pill. I was starting from near zero with Elixir and LiveView,
so one document didn&rsquo;t make me an expert. But it meaningfully closed the gap. I
understood the code better than I did before. I had something to refer back to.
And I started to feel like the codebase was actually mine.</p>
<p>That experience shaped how I think about using LLMs for coding. The tool works
fine. How you engage with it makes all the difference.</p>
<h2 id="practical-ways-to-keep-the-debt-in-check">Practical ways to keep the debt in check</h2>
<p><strong>Ask for explanations before you accept the code.</strong> Don&rsquo;t just run it. Ask the
LLM to walk you through what it did and why. This takes an extra minute and
catches a surprising number of cases where the code is technically correct but
built on assumptions you don&rsquo;t share.</p>
<p><strong>Ask for an explainer document for bigger decisions.</strong> Architecture choices,
non-obvious patterns, anything you&rsquo;re going to need to live with for a while:
ask the LLM to write it up in plain language. Keep it in the repo. Future you
will thank present you.</p>
<p><strong>Use Simon Willison&rsquo;s &ldquo;showboat&rdquo; approach to document what was built.</strong> The
<a href="https://github.com/simonw/showboat" target="_blank" rel="noopener noreffer "><code>showboat</code></a> tool &ldquo;creates executable demo
documents that show and prove an agent&rsquo;s work.&rdquo; (kind of like a Jupyter
notebook, but just markdown). The LLM walks through its output with explanation
and context. It&rsquo;s a great way to produce living documentation that captures not
just what the code does, but why it was written that way. It&rsquo;s a great tool,
but not suitable for every use case.</p>
<p><strong>Read the LLM&rsquo;s thinking, especially when debugging.</strong> Many LLMs can expose
their reasoning process. When you&rsquo;re stuck on a bug or trying to understand a
decision, asking the LLM to think out loud before answering is one of the
fastest ways to build genuine understanding rather than just getting an answer.</p>
<p><strong>Write the tests yourself.</strong> Even if you let the LLM write the implementation,
writing the tests forces you to reason about the behavior you actually want.
It&rsquo;s one of the best ways to make sure the mental model gets built. Of course,
it takes more time and it&rsquo;s not always possible, like with JoyConf where I
didn&rsquo;t know enough about the Elixir environment to write effective tests. But
when you can, it&rsquo;s a great way to stay in the driver&rsquo;s seat.</p>
<p><strong>Slow down at decision points.</strong> LLMs are fast. That&rsquo;s the point. But speed
can accelerate debt. When you hit a fork in the road (an architectural choice, a
tradeoff, a &ldquo;there are a few ways to do this&rdquo; moment) pause and do the reasoning
yourself, even if you use the LLM to help you think it through.</p>
<h2 id="the-goal-isnt-to-use-llms-less">The goal isn&rsquo;t to use LLMs less</h2>
<p>LLMs are genuinely useful and I don&rsquo;t plan to stop using them. The goal is to
stay in the driver&rsquo;s seat mentally, using them for leverage rather than as a
substitute for thinking.</p>
<p>A healthy LLM workflow and a debt-generating one can look identical from the
outside. The difference shows up later, when you need to understand, maintain, or
extend what you built. If you finish each session understanding what you built
and why, you&rsquo;re using the tool well. If you don&rsquo;t, you&rsquo;re taking out a loan.</p>
<p>And like financial debt, cognitive debt is a lot easier to avoid than to pay off.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://arxiv.org/abs/2506.08872" target="_blank" rel="noopener noreffer ">MIT study on cognitive debt</a></li>
<li><a href="https://github.com/simonw/showboat" target="_blank" rel="noopener noreffer ">Simon Willison&rsquo;s showboat tool</a></li>
<li><a href="https://github.com/grymoire7/joyconf" target="_blank" rel="noopener noreffer ">JoyConf repo</a></li>
<li><a href="https://tracyatteberry.com/posts/joyconf/" target="_blank" rel="noopener noreffer ">JoyConf story</a></li>
</ul>
]]></description></item><item><title>JoyConf: a live emoji reaction app for presentations</title><link>https://tracyatteberry.com/posts/joyconf/</link><pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/joyconf/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/joyconf/hero.png" referrerpolicy="no-referrer">
            </div><h1 id="i-built-a-live-emoji-reaction-app-for-my-upcoming-talk">I built a live emoji reaction app for my upcoming talk</h1>
<p>I have a talk coming up in a few weeks. It got rescheduled once, which gave me
extra time to prepare. The presentation itself is finished, so I used that time
to build an application that I think will make the talk more fun and engaging
for the audience.</p>
<p>I wanted to add some live audience interaction. The usual options, Slido,
Mentimeter, Poll Everywhere, are fine, but they&rsquo;re designed around Q&amp;A and
polls. What I actually wanted was simpler and more visual: live emoji reactions
that float up as an overlay on my slides while I&rsquo;m presenting. None of the
existing tools seem to do that. So I built my own, and I called it JoyConf.</p>
<p>It also gave me a reason to finally learn some Elixir.</p>
<hr>
<h2 id="what-it-does">What it does</h2>
<p>The flow is simple:</p>
<ol>
<li>You create a talk in the admin panel and get a QR code</li>
<li>You put the QR code on your title slide</li>
<li>Attendees scan it and land on a page with pre-defined set emoji buttons: ❤️ 😂 🙋🏻 👏 🤯, etc.</li>
<li>They tap a button, and the emoji floats up on their screen in real time</li>
<li>A Chrome extension running on your laptop picks up the same broadcast and
overlays the emoji directly in the lower right corner of your Google Slides presentation</li>
</ol>
<p>That&rsquo;s it. No accounts for attendees, no app to install, no configuration to
fiddle with. Scan, tap, react.</p>
<hr>
<h2 id="why-elixir-and-phoenix">Why Elixir and Phoenix</h2>
<p>I write Ruby day to day, so the syntax was familiar enough. But Elixir runs on
the BEAM, the Erlang virtual machine, which was built for soft real-time
systems with lots of concurrent connections. Phoenix LiveView lets you build
interactive, server-rendered UIs over WebSockets without writing much
JavaScript. And Phoenix PubSub gives you a message bus that lets any process
broadcast to any other, regardless of what type of process it is.</p>
<p>All three of those things are important for a system where dozens of phones are
sending events to a server that needs to fan them out to a slide presentation
and all the phones (for their own consolidated live view) in under a second.</p>
<p>LiveView is pretty magical, by the way. You define your UI as a function of
state, and Phoenix handles keeping the browser in sync. You get
WebSocket-backed interactivity without writing a single-page app.</p>
<hr>
<h2 id="how-it-works-under-the-hood">How it works under the hood</h2>
<p>There are three clients talking to one Phoenix server.</p>
<p><strong>Attendee phones</strong> connect via Phoenix LiveView, which manages the WebSocket
lifecycle automatically. When an attendee taps an emoji, it fires a <code>phx-click</code>
event to the server. The server checks a rate limiter (one reaction per session
every 3 seconds) and, if allowed, broadcasts the event to a PubSub topic keyed
by the talk slug: <code>&quot;reactions:my-talk&quot;</code>.</p>
<p><strong>The Chrome extension</strong> connects via a Phoenix Channel, a lower-level
WebSocket primitive. The extension can&rsquo;t use LiveView because it&rsquo;s not a web
page; it just needs to receive events. The <code>ReactionChannel</code> is subscribed to
the same PubSub topic, so when the attendee&rsquo;s broadcast lands, it gets
forwarded to the extension automatically.</p>
<p><strong>The admin browser</strong> is a standard LiveView protected by HTTP Basic Auth. You
create a talk, the server generates a slug from the title, and you get a QR
code pointing at the attendee URL.</p>
<p>The PubSub layer is what makes the architecture clean. <code>TalkLive</code> doesn&rsquo;t need
to know the Chrome extension exists. It just broadcasts on the topic, and
PubSub delivers it to everyone subscribed, whether that&rsquo;s a LiveView process, a
Channel process, or both.</p>
<p>Here&rsquo;s the entire broadcast path in code:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <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-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">&#34;react&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;emoji&#34;</span> <span class="o">=&gt;</span> <span class="n">emoji</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="nc">RateLimiter</span><span class="o">.</span><span class="n">allow?</span><span class="p">(</span><span class="n">socket</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Endpoint</span><span class="o">.</span><span class="n">broadcast!</span><span class="p">(</span><span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">talk</span><span class="o">.</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;new_reaction&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="ss">emoji</span><span class="p">:</span> <span class="n">emoji</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="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</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 the whole thing. Five lines. PubSub does the fan-out.</p>
<h3 id="rate-limiting-with-ets">Rate limiting with ETS</h3>
<p>The rate limiter is a <code>GenServer</code> that owns an ETS table. ETS is an in-memory
key/value store built into the BEAM runtime. It&rsquo;s extremely fast and,
crucially, you can configure it for concurrent reads without going through the
GenServer process. This matters because with many attendees tapping at once,
you don&rsquo;t want all those requests queuing up behind a single process.</p>
<p>The table stores <code>{session_id, last_reaction_at}</code>. The <code>allow?/1</code> function
looks up the session, checks if enough time has passed, and updates the
timestamp atomically. No database round-trip, no lock contention.</p>
<p>There&rsquo;s also a client-side rate limit in JavaScript: buttons are disabled for 3
seconds with a visible countdown timer. That&rsquo;s just UX, the real enforcement is
on the server.</p>
<h3 id="the-chrome-extension">The Chrome extension</h3>
<p>The extension has two parts: a popup where you enter the talk slug once, and a
content script injected into Google Slides pages that handles the actual
connection and overlay.</p>
<p>The content script connects a Phoenix WebSocket client to the server, joins the
<code>reactions:${slug}</code> channel, and listens for <code>new_reaction</code> messages. When one
arrives, it spawns a floating <code>&lt;span&gt;</code> element with a CSS animation that drifts
up and fades out.</p>
<hr>
<h2 id="it-never-works-on-the-first-try">It <em>never</em> works on the first try</h2>
<p><strong>Double emojis on slides.</strong> Early on, every reaction appeared twice on the
speaker&rsquo;s screen. The bug was that the Chrome extension was subscribing to
PubSub directly as well as receiving the Channel push. Two subscriptions, two
deliveries. The fix was removing the redundant subscription and letting the
Channel handle delivery exclusively.</p>
<p><strong>Fullscreen mode swallows the overlay.</strong> When you go fullscreen in Google
Slides, the browser creates a new stacking context. Any <code>position: fixed</code>
element on <code>&lt;body&gt;</code> becomes invisible. The fix was listening for
<code>fullscreenchange</code> events and re-parenting the overlay <code>&lt;div&gt;</code> into
<code>document.fullscreenElement</code> when it fires:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-javascript">
        <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-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">&#34;fullscreenchange&#34;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">overlay</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s2">&#34;joyconf-overlay&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">fullscreenElement</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">document</span><span class="p">.</span><span class="nx">fullscreenElement</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">overlay</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">overlay</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="p">});</span></span></span></code></pre></div></div>
<p>Not obvious, but straightforward once you know it.</p>
<p><strong>Button clicks were being swallowed.</strong> The initial implementation disabled the
emoji buttons immediately on click. That blocked the <code>phx-click</code> handler from
firing, so the server never received the event. The fix was deferring the
button disable to a <code>setTimeout(..., 0)</code>, which lets the click event propagate
before the buttons get disabled.</p>
<p><strong>The Chrome extension&rsquo;s origin.</strong> Chrome extensions run from a
<code>chrome-extension://</code> origin, which Phoenix&rsquo;s <code>check_origin</code> protection rejects
by default. One line in <code>endpoint.ex</code> fixes it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <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-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">socket</span> <span class="s2">&#34;/socket&#34;</span><span class="p">,</span> <span class="nc">JoyconfWeb.UserSocket</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">websocket</span><span class="p">:</span> <span class="p">[</span><span class="ss">check_origin</span><span class="p">:</span> <span class="no">false</span><span class="p">]</span></span></span></code></pre></div></div>
<hr>
<h2 id="the-tech-stack-briefly">The tech stack, briefly</h2>
<table>
	<thead>
			<tr>
					<th>Concern</th>
					<th>Choice</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Language / framework</td>
					<td>Elixir / Phoenix LiveView</td>
			</tr>
			<tr>
					<td>Real-time</td>
					<td>Phoenix PubSub + Channels</td>
			</tr>
			<tr>
					<td>Database</td>
					<td>PostgreSQL (Fly.io managed)</td>
			</tr>
			<tr>
					<td>Rate limiting</td>
					<td>ETS-backed GenServer</td>
			</tr>
			<tr>
					<td>QR codes</td>
					<td><code>eqrcode</code> hex package</td>
			</tr>
			<tr>
					<td>Deployment</td>
					<td>Fly.io</td>
			</tr>
			<tr>
					<td>Extension</td>
					<td>Chrome Manifest V3</td>
			</tr>
	</tbody>
</table>
<p>The database has one table: <code>talks</code>. Reactions are ephemeral and never
persisted. If the server restarts, in-flight reactions are lost, which is fine.</p>
<hr>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>JoyConf is an MVP. It does one thing and it&rsquo;s ready for me to use at my talk.
It&rsquo;s not productized, it&rsquo;s not super-polished, and it&rsquo;s likely not easy for
the average non-technical user to deploy.</p>
<p>Things that might be nice to add in the future:</p>
<ul>
<li>Ease of deployment for less technical users.</li>
<li>Reaction analytics tied to slides so you can see which parts of your talk landed</li>
<li>Support for other presentation tools beyond Google Slides</li>
</ul>
<p>For now, I&rsquo;m just going to go use it.</p>
<hr>
<p>The source code is on <a href="https://github.com/grymoire7/joyconf" target="_blank" rel="noopener noreffer ">GitHub</a>. If you
want to run your own instance, the README should have everything you need.</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>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>