<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>Portfolio - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/portfolio/</link><description>Portfolio - Tag - Tracy Atteberry</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>tracy@magicbydesign.com (Tracy Atteberry)</managingEditor><webMaster>tracy@magicbydesign.com (Tracy Atteberry)</webMaster><lastBuildDate>Fri, 08 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>Portfolio - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/portfolio/</link></image><atom:link href="https://tracyatteberry.com/tags/portfolio/" rel="self" type="application/rss+xml"/><item><title>TestGenAI</title><link>https://tracyatteberry.com/portfolio/testgenai/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/testgenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/testgenai/hero.jpg" referrerpolicy="no-referrer">
            </div><p>Building a presentation thingy.</p>
]]></description></item><item><title>TestGenAI: Building a Ruby CLI that writes your missing tests</title><link>https://tracyatteberry.com/posts/testgenai/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/testgenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/testgenai/hero.jpg" referrerpolicy="no-referrer">
            </div><h1 id="creating-a-gem-that-writes-your-missing-tests">Creating a gem that writes your missing tests</h1>
<p>No new project survives contact with the real world unscathed. We built
TestGenAI, ran it on itself, and it worked well. Then we ran it on another
codebase, and two things broke immediately. The fixes turned out to just as
interesting as the original build.</p>
<p>This is a walkthrough of how the tool works and what we learned when we took it
outside the greenhouse.</p>
<p>The code here is from <a href="https://github.com/grymoire7/testgenai" target="_blank" rel="noopener noreffer ">TestGenAI</a>, a
working Ruby CLI gem you can install and run against your own codebase.</p>
<h2 id="the-pipeline">The pipeline</h2>
<p>The pipeline has five stages:</p>
<ol>
<li>Scan your codebase to find classes and methods without test coverage</li>
<li>Build context for each untested method</li>
<li>Generate tests using an LLM with the mechanically curated context</li>
<li>Validate that the generated tests run and pass</li>
<li>Collect the results</li>
</ol>
<p>Each stage needs to be reliable enough that you can walk away and trust the
process to complete. That means handling errors gracefully, providing clear
output about what happened, and making it easy to pick up where things left off
if something breaks.</p>
<h2 id="finding-untested-code">Finding untested code</h2>
<p>Before you can generate tests, you need to know what needs testing. The right
approach depends on whether SimpleCov is available in the project.</p>
<p>If SimpleCov is set up, TestGenAI runs your test suite with <code>COVERAGE=true</code>,
reads the resulting <code>coverage/.resultset.json</code>, and uses AST parsing to find
methods where every executable line has zero hits. This scanner handles
partially-tested files correctly. It reports individual methods that were
never exercised, even if other methods in the same file have full coverage.</p>
<p>If SimpleCov isn&rsquo;t available, the scanner falls back to checking whether a spec
or test file exists for each source file. This approach is less accurate. A
file tested only through integration tests or through specs for its subclasses
will appear fully untested even if its methods are exercised constantly. The
SimpleCov scanner is worth setting up.</p>
<p>Both scanners share the same underlying logic for locating methods in source
files, which brings up something worth explaining.</p>
<h2 id="walking-the-ast">Walking the AST</h2>
<p>To locate methods, TestGenAI parses each Ruby source file into an abstract
syntax tree and walks it recursively. The walker looks for <code>:def</code> and <code>:defs</code>
nodes (instance and class methods), tracks the current class/module namespace,
and records each method&rsquo;s file, class, name, and line range.</p>
<p>That line range matters. The SimpleCov scanner uses it to check whether any
executable lines in the method had zero hits. A <code>nil</code> in SimpleCov&rsquo;s coverage
array means a line isn&rsquo;t executable, like a blank line, a comment, or an <code>end</code>.
The scanner filters those out before checking for zeros, so it only flags
methods where runnable code was never touched.</p>
<h3 id="the-parser-compatibility-problem">The parser compatibility problem</h3>
<p>To parse Ruby, the gem relies on the <code>parser</code> gem. In older versions, you&rsquo;d
call <code>Parser::CurrentRuby.parse(source)</code> and get back an AST. This worked fine
until Ruby 3.4, which switched its internal default parser to prism. Using
<code>Parser::CurrentRuby</code> with Ruby 3.4 produces warnings, and in some
configurations it fails entirely.</p>
<p>The prism project ships a compatibility shim,
<code>Prism::Translation::ParserCurrent</code>, that produces the same AST node types as
the old parser gem. The AST-walking code works unchanged. The only question is
which one to load.</p>
<p>The solution is a small file that runs at load time and sets a constant:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Testgenai</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">Gem</span><span class="o">::</span><span class="no">Version</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="no">RUBY_VERSION</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="no">Gem</span><span class="o">::</span><span class="no">Version</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="s2">&#34;3.4&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">require</span> <span class="s2">&#34;prism&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">CurrentParser</span> <span class="o">=</span> <span class="no">Prism</span><span class="o">::</span><span class="no">Translation</span><span class="o">::</span><span class="no">ParserCurrent</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">require</span> <span class="s2">&#34;parser/current&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">CurrentParser</span> <span class="o">=</span> <span class="no">Parser</span><span class="o">::</span><span class="no">CurrentRuby</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The rest of the codebase calls <code>CurrentParser.parse(source)</code> and never thinks
about which parser is underneath. The pattern of version check at load time,
constant as the abstraction is a clean way to handle the same kind of
compatibility gap you&rsquo;ll run into whenever Ruby ships a significant internal
change.</p>
<h2 id="context-generation-and-validation">Context, generation, and validation</h2>
<p>When you ask an LLM to write tests for a method, you can&rsquo;t just paste in the
method body. It needs the full class, the dependencies that file requires,
examples of how the method is called elsewhere in the codebase, and existing
test files it can match in style. Context quality is where quick-and-dirty AI
test generators fall apart, too little and the tests don&rsquo;t compile, too much
and you hit token limits.</p>
<p>The generator builds a prompt from all of that, sends it to the LLM via the
<code>ruby_llm</code> gem (which keeps the generator code provider-agnostic), and strips
any markdown fences from the response before passing it to the validator.</p>
<p>The validator writes the code to a temp file, runs <code>bundle exec rspec</code> or the
Minitest equivalent, and distinguishes between three outcomes: the file failed
to load (syntax errors, undefined constants), the tests ran but failed, or the
tests passed. Each outcome needs different handling. A file that doesn&rsquo;t load
gets deleted immediately because it&rsquo;s useless. A file that runs but fails gets
its error output fed back to the LLM for a retry.</p>
<p>The pipeline retries up to three times, passing failure details back each time.
LLMs are reasonably good at fixing specific errors when told what went wrong.
Undefined constants and wrong require paths almost always resolve in one retry.
More complex failures, like incorrect behavior assumptions, may not, and those
end up in a failed bucket for manual review.</p>
<h2 id="then-we-ran-it-on-a-real-project">Then we ran it on a real project</h2>
<p>The first external test run revealed two problems, both on the same day.</p>
<p>The first: generated tests were syntactically valid, ran, and passed — but they
looked nothing like the rest of the project&rsquo;s test suite. Wrong authentication
setup, wrong factory usage, helpers that weren&rsquo;t available. Tests that
technically pass but violate project conventions create a maintenance burden.</p>
<p>The second: the tool was silently destroying existing tests. When a spec file
already existed at the output path, the pipeline would overwrite it with the
newly generated content. Any tests already in that file were gone.</p>
<p>Both problems make complete sense in retrospect. The tool had only ever run on
its own codebase, where it was always generating new files and where the
conventions were deeply familiar to the model from the context it was seeing. A
different project broke both assumptions.</p>
<h2 id="fixing-the-conventions-gap">Fixing the conventions gap</h2>
<p>The core problem is that the LLM knows what your method does, but it doesn&rsquo;t
know how your team writes tests. It doesn&rsquo;t know that you authenticate in
<code>before</code> blocks a certain way, or that you have specific factory traits
available, or that you&rsquo;re not using <code>rails-controller-testing</code> so <code>assigns</code>
isn&rsquo;t an option.</p>
<p>The fix is a conventions system with two parts.</p>
<p><code>ConventionsExtractor</code> scans your existing test files and pulls out mechanical
facts: the most common authentication setup pattern, available factory traits
from your factories directory, frequently stubbed objects, whether
transactional fixtures are disabled and how cleanup is handled, and whether
specific helpers are unavailable based on what&rsquo;s in your Gemfile. These aren&rsquo;t
judgments, they&rsquo;re observations extracted directly from the code.</p>
<p><code>ConventionsSynthesizer</code> takes those raw facts and sends them to the LLM with a
prompt asking it to write a concise conventions guide explaining the rule
behind each pattern. The result is plain prose. Something like &ldquo;authentication
is handled in <code>before</code> blocks using <code>session[:user_id] =</code> rather than Devise
helpers; use this pattern consistently.&rdquo; That text gets prepended to every
generation prompt.</p>
<p>The synthesized guide is cached to <code>spec/conventions.md</code> and invalidated
automatically when spec files or Gemfiles change. Regenerating it costs one LLM
call.</p>
<p>Enable it with the <code>--conventions</code> flag:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">testgenai generate --provider anthropic --model claude-opus-4-7 --conventions</span></span></code></pre></div></div>
<p>Add <code>spec/conventions.md</code> to your <code>.gitignore</code>. It&rsquo;s a derived artifact and
probably not something to check in.</p>
<h2 id="fixing-the-overwrite-problem">Fixing the overwrite problem</h2>
<p>The overwrite bug was straightforward to diagnose and subtle to fix correctly.</p>
<p>The naive fix would be to skip generation if a spec file already exists. That&rsquo;s
less than ideal. The point is to add tests for untested methods, and
partially-tested files are the most common case.</p>
<p>Better behavior is to inject the generated tests into the existing file. The
pipeline now reads existing content before doing anything else. It combines
existing content with the newly generated code and validates the combined file.
If it passes, the combined content is written to the spec file.</p>
<p>If it fails, the pipeline restores the original file exactly as it was and
writes the generated-only code to a fallback path. A method in
<code>lib/payments/processor.rb</code> that already has a
<code>spec/payments/processor_spec.rb</code> would get its fallback at
<code>spec/payments/processor_context_spec.rb</code> (using the method name to scope the
filename). The failure output tells you where to find it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><pre tabindex="0"><code>  ✗ Payments::Processor#context failed after 3 attempt(s)
    → Generated tests saved to spec/payments/processor_context_spec.rb for manual review</code></pre></div>
<p>You end up with your original tests intact and the generated attempt sitting
somewhere you can look at it and decide what to do.</p>
<h2 id="running-it">Running it</h2>
<p>Install the gem and point it at your project:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gem install testgenai
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> your_project
</span></span><span class="line"><span class="cl">testgenai generate --provider anthropic --model claude-opus-4-7</span></span></code></pre></div></div>
<p>Or add it to your Gemfile in the development group and use <code>bundle exec</code>.
Configuration can also come from environment variables:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">TESTGENAI_PROVIDER</span><span class="o">=</span>anthropic
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">TESTGENAI_MODEL</span><span class="o">=</span>claude-opus-4-7
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span>your_api_key
</span></span><span class="line"><span class="cl">testgenai generate --conventions</span></span></code></pre></div></div>
<p>Three diagnostic commands are available before you commit to a full run:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">testgenai scan      <span class="c1"># find untested methods without making any API calls</span>
</span></span><span class="line"><span class="cl">testgenai context   <span class="c1"># show what context would be sent to the LLM for each method</span></span></span></code></pre></div></div>
<p><code>scan</code> gives you a picture of your coverage gaps. <code>context</code> is useful for
understanding what the LLM will see before spending API credits.</p>
<p>The goal isn&rsquo;t to replace the developer who understands the code and makes
decisions about testing. It&rsquo;s to handle the mechanical work: setting up
describe blocks, wiring test data, writing happy-path coverage. Then you can
spend your time on the parts that actually need your judgment. The second
project taught us that &ldquo;mechanical&rdquo; is more context-dependent than it looks.</p>
]]></description></item><item><title>MockOpenAI</title><link>https://tracyatteberry.com/portfolio/mockopenai/</link><pubDate>Fri, 20 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/mockopenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mockopenai/mockopenai_hero.jpg" referrerpolicy="no-referrer">
            </div><p>Building a gem.</p>
]]></description></item><item><title>Building MockOpenAI: a weekend MVP story</title><link>https://tracyatteberry.com/posts/mockopenai/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/mockopenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mockopenai/mockopenai_hero.jpg" referrerpolicy="no-referrer">
            </div><h1 id="building-mockopenai-a-weekend-mvp-story">Building MockOpenAI: a weekend MVP story</h1>
<p>Last weekend I built and published a Ruby gem. From idea to published thing
in about four days. Here&rsquo;s how it went, including the part where I had to
reconsider whether I&rsquo;d built something useful at all.</p>
<h2 id="friday-20-ideas-one-bet">Friday: 20 ideas, one bet</h2>
<p>I&rsquo;m between jobs right now. Good position to be in if you like building things,
terrible position to be in if you like eating. So I&rsquo;ve been running a little
experiment: each weekend, pick one small idea and see if I can ship it.</p>
<p>Friday&rsquo;s job was to generate and pick an idea. I sat down with an AI and
brainstormed 20 candidates. Developer tools. Content products. Micro-SaaS. I
narrowed it down to one: a local mock server for testing OpenAI-compatible
APIs.</p>
<p>The pitch to myself was simple. I write a lot of Ruby. I write a lot of tests.
Testing code that talks to LLMs is a bit annoying because, while the happy
path is easy to mock, some of the failure modes and edge cases can be more of a
pain. There had to be a better way.</p>
<p>By end of day Friday I had a repo, a gemspec, and a clear plan.</p>
<h2 id="saturday-build-day">Saturday: build day</h2>
<p>The core idea for MockOpenAI is that it&rsquo;s a real HTTP server running on
localhost, not a mock object or a stub. Your application code talks to it
exactly the way it would talk to the LLM provider in production. You just point
your client at <code>http://localhost:4000</code> instead of the usaul API endpoint.</p>
<p>That distinction makes a difference, I think. With a real HTTP server you can
test things that object-level mocking can&rsquo;t touch: actual TCP timeouts,
truncated streaming responses, retry headers. The kind of failure modes that
bite you in production but never show up in your test suite because you stubbed
them away.</p>
<p>The architecture is deliberately simple. A Rack server reads a shared JSON
state file on every request. Your tests write rules to that file. The server is
stateless. No client wrapping, no monkey-patching, no magic.</p>
<p>Here&rsquo;s what using it looks like:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles a rate limit&#34;</span><span class="p">,</span> <span class="ss">:mock_openai_rate_limit</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="no">MyService</span><span class="o">.</span><span class="n">call_llm</span><span class="p">(</span><span class="s2">&#34;Hello&#34;</span><span class="p">)</span> <span class="p">}</span><span class="o">.</span><span class="n">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">RubyLLM</span><span class="o">::</span><span class="no">RateLimitError</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles mixed outcomes&#34;</span><span class="p">,</span> <span class="ss">:mock_openai</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">MockOpenAI</span><span class="o">.</span><span class="n">set_responses</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 1&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 2&#34;</span><span class="p">,</span> <span class="ss">failure_mode</span><span class="p">:</span> <span class="ss">:timeout</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 3&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;Done&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="no">MyService</span><span class="o">.</span><span class="n">step1</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">eq</span><span class="p">(</span><span class="s2">&#34;OK&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="no">MyService</span><span class="o">.</span><span class="n">step2</span> <span class="p">}</span><span class="o">.</span><span class="n">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">Timeout</span><span class="o">::</span><span class="no">Error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="no">MyService</span><span class="o">.</span><span class="n">step3</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">eq</span><span class="p">(</span><span class="s2">&#34;Done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The failure modes are:</p>
<table>
	<thead>
			<tr>
					<th>Mode</th>
					<th>What it does</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>:timeout</code></td>
					<td>Sleeps then closes the connection without responding</td>
			</tr>
			<tr>
					<td><code>:rate_limit</code></td>
					<td>Returns HTTP 429 with an OpenAI-format error body</td>
			</tr>
			<tr>
					<td><code>:malformed_json</code></td>
					<td>Returns truncated JSON that causes a parse error in your client</td>
			</tr>
			<tr>
					<td><code>:internal_error</code></td>
					<td>Returns HTTP 500</td>
			</tr>
			<tr>
					<td><code>:truncated_stream</code></td>
					<td>Sends partial SSE chunks then closes the connection</td>
			</tr>
	</tbody>
</table>
<p>You can also mix success and failure in a single test:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles mixed outcomes&#34;</span><span class="p">,</span> <span class="ss">:mock_openai</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">MockOpenAI</span><span class="o">.</span><span class="n">set_responses</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 1&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 2&#34;</span><span class="p">,</span> <span class="ss">failure_mode</span><span class="p">:</span> <span class="ss">:timeout</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 3&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;Done&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Saturday was productive. By end of day I had all the core classes written
TDD-style: <code>Config</code>, <code>State</code>, <code>Matcher</code>, <code>ResponseBuilder</code>,
<code>TemplateRenderer</code>, all five failure mode classes. The code was written, the
tests passed, and life was good.</p>
<h2 id="sunday-documentation-and-shipping">Sunday: documentation and shipping</h2>
<p>Sunday was docs day. I set up a Jekyll site and wrote the README. I added an
Anthropic endpoint too, because my personal projects use both.</p>
<p>I also migrated the first personal project to use MockOpenAI. That went
smoothly. The HTTP-level fidelity made a few tests a little more honest than
they&rsquo;d been with simple stubs at the client level.</p>
<h2 id="monday-the-uncomfortable-question">Monday: the uncomfortable question</h2>
<p>Monday I migrated a second personal project. This one used a helper module I&rsquo;d
written a while back to stub LLM calls. Just a few lines of code. It worked fine
for that project.</p>
<p>I stared at that code for a while. Here it is:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">RubyLLMMocks</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">mock_ruby_llm_chat</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span> <span class="ss">error</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">error</span>
</span></span><span class="line"><span class="cl">      <span class="n">allow</span><span class="p">(</span><span class="no">RubyLLM</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:chat</span><span class="p">)</span><span class="o">.</span><span class="n">and_raise</span><span class="p">(</span><span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">mock_response</span> <span class="o">=</span> <span class="n">instance_double</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Message</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">content</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nb">inspect</span><span class="p">:</span> <span class="s2">&#34;RubyLLM::Message(content: </span><span class="si">#{</span><span class="n">content</span><span class="o">.</span><span class="n">inspect</span><span class="si">}</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">mock_chat</span> <span class="o">=</span> <span class="n">instance_double</span><span class="p">(</span><span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="p">,</span> <span class="ss">ask</span><span class="p">:</span> <span class="n">mock_response</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">allow</span><span class="p">(</span><span class="no">RubyLLM</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:chat</span><span class="p">)</span><span class="o">.</span><span class="n">and_return</span><span class="p">(</span><span class="n">mock_chat</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Example:</span>
</span></span><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles general ruby_llm errors gracefully&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">error</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Error</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="kp">nil</span><span class="p">,</span> <span class="s2">&#34;Unexpected error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">mock_ruby_llm_chat</span><span class="p">(</span><span class="ss">error</span><span class="p">:</span> <span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">generator</span> <span class="o">=</span> <span class="n">described_class</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">options</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="n">generator</span><span class="o">.</span><span class="n">generate</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">to</span> <span class="n">output</span><span class="p">(</span><span class="sr">/Error.*Unexpected error/m</span><span class="p">)</span><span class="o">.</span><span class="n">to_stdout</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">and</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">SystemExit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>That&rsquo;s it. Fifteen lines, no gem dependency, works perfectly for a project that
uses RubyLLM as a wrapper. The error case is handled with <code>and_raise</code>. Clean.</p>
<p>So the question had to be asked: did I just build a solution in search of a
problem?</p>
<p>After sitting with it, I don&rsquo;t think so. But I did have to sharpen my thinking
about <em>when</em> MockOpenAI actually earns its place versus when a helper method is
the right call.</p>
<p>The short version: if you&rsquo;re using a wrapper library like RubyLLM for all your
LLM calls, and you only need happy-path responses and exception simulation in
unit tests, the 15-line helper is probably the right answer. It&rsquo;s less to
maintain, has no extra dependencies, and does the job.</p>
<p>MockOpenAI is the right call when you need the actual HTTP layer in the
picture. When you&rsquo;re using the raw OpenAI or Anthropic client directly. When
you&rsquo;re running integration or system tests that make real HTTP connections.
When you need to test what happens when TCP actually times out, or when a
streaming response gets cut off halfway through, or when your retry logic
processes a <code>Retry-After</code> header.</p>
<p>Those are real problems. They&rsquo;re just not every project&rsquo;s problems. I added a
<a href="https://grymoire7.github.io/mockopenai/when-not-to-use.html" target="_blank" rel="noopener noreffer ">when not to use</a>
page to the docs to make the tradeoffs explicit.</p>
<h2 id="what-id-do-differently">What I&rsquo;d do differently</h2>
<p>One thing I&rsquo;d change: I&rsquo;d research the problem space a bit more to make sure I
had a better understanding of problem scope and existing solution. (Especially
ones I wrote myself!) The tool is solid, but I made some assumptions about the
breadth of problems it would solve for. That&rsquo;s a classic weekend MVP trap I
suppose. You&rsquo;re so focused on building that you skip the a bit of due diligence
you think you don&rsquo;t need.</p>
<p>The gem is published, the docs are live, and it works. The scope is narrower
than I originally thought, but the use case is real. That feels like an honest
result for a long weekend.</p>
<hr>
<p><em>MockOpenAI is a Ruby gem for testing OpenAI-compatible and Anthropic APIs
locally. References:</em></p>
<ul>
<li>Source: <a href="https://github.com/grymoire7/mockopenai" target="_blank" rel="noopener noreffer ">github.com/grymoire7/mockopenai</a>.</li>
<li>Docs: <a href="https://grymoire7.github.io/mockopenai" target="_blank" rel="noopener noreffer ">grymoire7.github.io/mockopenai</a>.</li>
<li>Landing page: <a href="https://tracyatteberry.com/mockopenai" target="_blank" rel="noopener noreffer ">tracyatteberry.com/mockopenai</a>.</li>
</ul>
]]></description></item><item><title>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><item><title>Jojo</title><link>https://tracyatteberry.com/portfolio/jojo/</link><pubDate>Fri, 20 Feb 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/jojo/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/jojo/landing_page.png" referrerpolicy="no-referrer">
            </div><p>Building a CLI for turning job applications into marketing campaigns. Jojo
helps you create personalized, engaging applications that stand out from the
crowd. It generates tailored cover letters, optimizes your resume for ATS, and
even creates a custom portfolio website for each application. With Jojo, you
can turn your job search into a marketing campaign that gets noticed.</p>
]]></description></item><item><title>Enshortener</title><link>https://tracyatteberry.com/portfolio/enshortener/</link><pubDate>Fri, 16 Jan 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/enshortener/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/enshortener/screenshot.png" referrerpolicy="no-referrer">
            </div><p>Building a personal URL shortener deployable via SFTP on shared hosting.</p>
]]></description></item><item><title>Building a Ruby CLI gem for Hyrum's Law</title><link>https://tracyatteberry.com/posts/hyrum/</link><pubDate>Thu, 20 Nov 2025 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/hyrum/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/hyrum/hyrum_top.png" referrerpolicy="no-referrer">
            </div><h1 id="building-a-ruby-cli-gem-for-hyrums-law">Building a Ruby CLI gem for Hyrum&rsquo;s Law</h1>
<p>When you build a public API, users will depend on behaviors you never intended
to guarantee. It&rsquo;s called Hyrum&rsquo;s Law, and it&rsquo;s particularly tricky when it
comes to error messages. Change &ldquo;User not found&rdquo; to &ldquo;No such user exists&rdquo; and
someone&rsquo;s regex breaks in production at 2am.</p>
<p>I built Hyrum to solve for  this. It&rsquo;s a Ruby CLI gem that uses AI to generate
variations of status messages, ensuring users never become dependent on exact
wording. It evolved from a single-provider tool into a multi-provider platform
that cut costs by 10x, reduced code complexity from 12 to 1, and added quality
validation for AI-generated content.</p>
<h2 id="the-problem-with-predictable-messages">The problem with predictable messages</h2>
<p><a href="https://www.laws-of-software.com/laws/hyrum/" target="_blank" rel="noopener noreffer ">Hyrum&rsquo;s Law</a> states that
all observable behaviors of your system will be depended on by somebody.
This creates a dilemma for API designers: you want clear, consistent
error messages, but you don&rsquo;t want users parsing them as if they were
structured data.</p>
<p>Error codes help with this. Return <code>404</code> or <code>E_NOT_FOUND</code> and the
message text can evolve independently. But this only works if you&rsquo;re
disciplined about using codes for everything that matters. In practice,
some context lives in the message text, and someone will parse it.</p>
<p>The traditional solution is thorough documentation warning against this.
The pragmatic solution is accepting that some percentage of users will
do it anyway. The solution presented here is to make the messages unpredictable
by design.</p>
<h2 id="building-the-initial-solution">Building the initial solution</h2>
<p>The first version was straightforward. The gem takes a message like &ldquo;The
server refuses the attempt to brew coffee with a teapot&rdquo; and generates
code in your language of choice (Ruby, JavaScript, Python, Java, or
JSON) that returns variations at random:</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">module</span> <span class="nn">Messages</span>
</span></span><span class="line"><span class="cl">  <span class="no">MESSAGES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="ss">e418</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Invalid Brewing Method&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Teapot not designed for coffee brewing&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Please use a suitable brewing device&#34;</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 class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</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">message</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">MESSAGES</span><span class="o">[</span><span class="n">key</span><span class="o">].</span><span class="n">sample</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>I started with OpenAI&rsquo;s API via the <code>ruby-openai</code> gem. It worked well
enough for the core use case. But eventually, two problems emerged.</p>
<p>First, cost. Running <code>gpt-4</code> for simple message generation was using a
chainsaw to cut butter. Second, vendor lock-in. Some projects had
Anthropic credits, others used local Ollama models. I needed to support
multiple providers without maintaining provider-specific code paths.</p>
<h2 id="the-migration-decision">The migration decision</h2>
<p>There were three options:</p>
<ol>
<li>Build custom adapters for each provider</li>
<li>Find an abstraction layer that handled the differences</li>
<li>Accept the limitation and move on</li>
</ol>
<p>Building custom adapters would provide complete control, but at the cost
of maintaining provider-specific logic as APIs evolved. Option three was
tempting but unsatisfying.</p>
<p>I chose option two, migrating to
<a href="https://github.com/crmne/ruby_llm" target="_blank" rel="noopener noreffer ">ruby_llm</a>. More than
swapping dependencies, this was a fundamental architecture change that
would affect testing strategy, error handling, configuration, and the
public API.</p>
<h2 id="key-technical-decisions">Key technical decisions</h2>
<h3 id="cost-optimization-through-model-selection">Cost optimization through model selection</h3>
<p>The most impactful decision was switching from premium to budget models.
For Anthropic, this meant <code>claude-sonnet-4</code> to <code>claude-haiku-20250514</code>,
a roughly <em><strong>10x cost reduction</strong></em>.</p>
<p>This wasn&rsquo;t about being cheap. (Okay, maybe it was a little bit about that.) It
was (mostly) about matching model capability to task complexity. Generating
three variations of &ldquo;Resource not found&rdquo; doesn&rsquo;t require deep reasoning. Budget
models handle it perfectly well. The quality remained identical while costs
dropped by an order of magnitude.</p>
<h3 id="testing-strategy-mock-at-the-right-level">Testing strategy: mock at the right level</h3>
<p>The original implementation used VCR to record HTTP interactions. This
is a common pattern, but it had problems:</p>
<ul>
<li>Maintaining cassettes for 10+ providers would be tedious</li>
<li>Tests would break when ruby_llm changed request formats</li>
<li>We would be testing ruby_llm&rsquo;s HTTP implementation, not our code</li>
</ul>
<p>The better approach: mock at the ruby_llm interface level. Instead of
recording HTTP traffic, we mock <code>RubyLLM.chat()</code> directly. One mock
setup works for all providers. Tests are faster, more maintainable, and
focused on our actual logic.</p>
<p>This eliminated the need for VCR and WebMock entirely, <em><strong>removing two
dependencies.</strong></em> 🎊</p>
<p>As an additional safeguard, I set up a GitHub Actions workflow that runs
the full test suite on every push. This catches regressions before they
reach main and provides confidence when accepting contributions. It&rsquo;s a
small addition that pays dividends in long-term maintainability.</p>
<h3 id="code-simplification-through-extraction">Code simplification through extraction</h3>
<p><code>FakeGenerator</code> started at 298 lines with embedded message data.
Extracting the messages to an external JSON file and refactoring the
logic brought it down to 36 lines. That&rsquo;s an 88% reduction.</p>
<p>Another big win was in <code>AiGenerator</code>. By letting ruby_llm handle provider
differences, the <em><strong>cyclomatic complexity dropped from 12 to 1</strong></em>. Twelve
decision points (checking provider types, handling edge cases) collapsed into a
single code path.</p>
<p>This is the value of a good abstraction layer. It reduces the lines of code,
sure, but it also reduces the number of things you have to think about.</p>
<p>To be honest, this is also currently a pain point. We swapped ruby-openai&rsquo;s
abastraction for a better one, but in an evolving ecosystem, we may need to
swap again. The key is that the architecture is now flexible enough to
accommodate future changes more easily.</p>
<h3 id="breaking-changes-as-a-design-tool">Breaking changes as a design tool</h3>
<p>The migration required environment variable changes:</p>
<ul>
<li><code>OPENAI_ACCESS_TOKEN</code> → <code>OPENAI_API_KEY</code></li>
<li><code>OLLAMA_URL</code> → <code>OLLAMA_API_BASE</code></li>
</ul>
<p>I considered adding migration helpers to detect old variables and warn
users. But the gem was pre-1.0 with minimal adoption. Adding complexity
for hypothetical users would hurt future maintainability more than it
helped current users.</p>
<p>The cleaner approach: document the breaking changes clearly, provide a
migration guide, and move forward with consistent naming. Sometimes the
right trade-off is accepting short-term pain for long-term simplicity.</p>
<h2 id="implementation-approach">Implementation approach</h2>
<p>I followed a disciplined TDD approach for the migration:</p>
<ol>
<li>Write failing tests for <code>AiGenerator</code></li>
<li>Implement minimal code to pass</li>
<li>Add error handling tests</li>
<li>Implement error handling</li>
<li>Verify across multiple providers</li>
</ol>
<p>Each commit represented a logical unit of work with a clear purpose. The
git history tells a story: dependency updates, test infrastructure, new
generator implementation, factory updates, cleanup, documentation.</p>
<p>This matters for maintainability. Six months from now, when I need to
add a new provider or debug an edge case, the git history explains not
only what changed but why.</p>
<h2 id="validating-non-deterministic-output">Validating non-deterministic output</h2>
<p>Getting AI to generate message variations is great, but
how do you know if the variations are any good?</p>
<p>Building a system that validates the quality of non-deterministic output
requires deeper thinking about what &ldquo;quality&rdquo; even means in this context.</p>
<h3 id="defining-useful-variation">Defining useful variation</h3>
<p>A good variation needs two properties:</p>
<ol>
<li><strong>Semantic similarity</strong> - It preserves the original message&rsquo;s meaning</li>
<li><strong>Lexical diversity</strong> - It uses different wording than other variations</li>
</ol>
<p>These goals exist in tension. Perfect similarity means identical text.
Perfect diversity means unrelated messages. The sweet spot is variations
that mean the same thing but say it differently.</p>
<p>I created a validation system that measures both metrics
and combines them into an overall quality score. This lets you validate
generated variations automatically.</p>
<h3 id="the-initial-design-mistake">The initial design mistake</h3>
<p>My first implementation compared variations to each other. Generate
five variations, measure how similar they are as a group, done. This
seemed logical until I tested it.</p>
<p>The problem: variations could be highly similar to each other but
completely different from the original message. A set of variations
about network timeouts would score well even if the original message
was about authentication failures. They were similar to each other,
but wrong.</p>
<p>The fix was obvious in hindsight: compare each variation to the
original message, not to other variations. Semantic similarity measures
how well each variation preserves the user&rsquo;s intent. Lexical diversity
measures how much the variations differ from each other. Two separate
concerns, two separate comparisons.</p>
<h3 id="semantic-similarity-with-embeddings">Semantic similarity with embeddings</h3>
<p>Measuring semantic similarity requires understanding meaning, not just
matching words. &ldquo;Server error&rdquo; and &ldquo;Internal server failure&rdquo; share
minimal text but convey the same concept. Simple string comparison
would fail.</p>
<p>The solution: embedding models. These convert text into high-dimensional
vectors where semantically similar content clusters together. Calculate
the cosine similarity between the original message&rsquo;s embedding and each
variation&rsquo;s embedding, and you have a numeric score for how well meaning
is preserved.</p>
<p>I designed this to be provider-agnostic from the start, learning from
the earlier migration experience. The validator uses <code>RubyLLM.embed()</code>
which works with any provider that supports embeddings (OpenAI, Google,
etc.). When embeddings aren&rsquo;t available, it falls back to a simpler
word overlap heuristic.</p>
<p>This graceful degradation was important. Users without embedding access
still get validation, just with reduced accuracy. The feature doesn&rsquo;t
silently fail or block users.</p>
<h3 id="api-design-for-optional-features">API design for optional features</h3>
<p>Quality validation needed to be opt-in. The core workflow is &ldquo;generate
variations and use them.&rdquo; Adding validation steps would slow things down
and require configuration. It needed to enhance the workflow without
disrupting it.</p>
<p>The CLI design reflects this:</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"># Basic usage - no validation</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opt into validation</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby --validate
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Use in CI/CD with strict mode</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby --validate --strict --min-quality <span class="m">75</span></span></span></code></pre></div></div>
<p>Validation is off by default. Enable it when you want quality metrics.
Use <code>--strict</code> to fail builds when quality is too low. Use <code>--show-scores</code>
to include metrics in generated output.</p>
<p>Each flag serves a specific use case without cluttering the happy path.
This is backward compatible and makes the feature discoverable through
<code>--help</code> without overwhelming new users.</p>
<h2 id="what-i-learned">What I learned</h2>
<h3 id="shipping-a-ruby-gem-is-more-accessible-than-i-expected">Shipping a Ruby gem is more accessible than I expected</h3>
<p>I hadn&rsquo;t published a gem before this project. The Ruby ecosystem makes
it surprisingly straightforward: follow conventions for directory
structure, add a gemspec, and <code>gem build</code> handles the rest. RuboCop
enforces community standards, and RSpec provides solid testing patterns.</p>
<p>Most of the learning curve wasn&rsquo;t in the tooling. It was in the design
decisions around versioning, breaking changes, and API stability. Understanding
when to bump major vs minor versions, when breaking changes are acceptable, and
how much backward compatibility to maintain. These are judgment calls that come
with experience, not documentation per se.</p>
<h3 id="abstractions-have-a-cost-and-a-benefit">Abstractions have a cost and a benefit</h3>
<p>Ruby_llm&rsquo;s abstraction eliminated provider-specific code paths. But it
also added a dependency and gave up some provider-specific features
(like Anthropic&rsquo;s prompt caching). The trade-off made sense because the
gem&rsquo;s core use case doesn&rsquo;t need advanced features. Your mileage will
vary.</p>
<h3 id="model-selection-is-a-design-decision">Model selection is a design decision</h3>
<p>Defaulting to budget models wasn&rsquo;t about minimizing costs. It was about
right-sizing capability to task complexity. When your task genuinely
needs advanced reasoning, use advanced models. When it doesn&rsquo;t, you&rsquo;re
paying for capability you&rsquo;re not using.</p>
<h3 id="testing-at-the-right-abstraction-level-matters">Testing at the right abstraction level matters</h3>
<p>Mocking at the HTTP level tests the wrong thing. Mocking at the library
interface level tests your code. The latter is almost always better
unless you&rsquo;re specifically testing HTTP behavior.</p>
<h3 id="breaking-changes-are-acceptable-in-context">Breaking changes are acceptable in context</h3>
<p>Pre-1.0 software with limited adoption is the right time to make
breaking changes. Adding backward compatibility for a handful of users
creates technical debt that affects every future user. Sometimes the
generous thing is to break things cleanly.</p>
<h3 id="user-feedback-catches-design-flaws-early">User feedback catches design flaws early</h3>
<p>My initial quality validation design seemed sound in theory. It measured
variation quality as a group property. But the first test revealed the
flaw: variations could be similar to each other while being completely
unrelated to the original message.</p>
<p>This is why you test with real examples before building the whole system.
The fix (comparing to the original message) was trivial to implement
early. It would have been painful to retrofit later after building an
entire validation pipeline on the wrong assumption.</p>
<p>The lesson: design mistakes are inevitable. What matters is catching them
before they become weight-bearing walls in your architecture.</p>
<h3 id="graceful-degradation-beats-hard-dependencies">Graceful degradation beats hard dependencies</h3>
<p>Embedding models provide superior semantic similarity measurement. But
requiring them would block users whose AI providers don&rsquo;t support
embeddings. The word overlap fallback isn&rsquo;t as accurate, but it&rsquo;s
better than nothing.</p>
<p>This pattern appears throughout the gem. Can&rsquo;t access embeddings? Use
heuristics. Provider doesn&rsquo;t support structured output? Parse text.
Each graceful degradation expands the set of valid configurations.</p>
<p>The alternative is failing fast with clear errors. Both approaches are
valid, but for a tool that works across many providers, degradation
creates a better experience than strict requirements.</p>
<h3 id="validation-changes-what-done-means">Validation changes what &ldquo;done&rdquo; means</h3>
<p>Before quality validation, &ldquo;done&rdquo; meant &ldquo;generates variations.&rdquo; After,
it meant &ldquo;generates variations that preserve meaning while varying
wording.&rdquo; This shift changed the entire value proposition.</p>
<p>The interesting part is that validation makes the AI output more
trustworthy without requiring a better AI model. Same model, same cost,
but now you have quantitative confidence in the results. That&rsquo;s the
leverage of good metrics.</p>
<h2 id="the-result">The result</h2>
<p>Hyrum now supports 11 AI providers (OpenAI, Anthropic, Gemini, Ollama,
Mistral, DeepSeek, Perplexity, OpenRouter, Vertex AI, AWS Bedrock,
GPUStack) through a unified interface. The codebase is simpler,
tests are faster, and costs are 10x lower.</p>
<p>Quality validation adds confidence without complexity. Generate variations,
validate they preserve meaning while varying wording, and integrate the
results into your codebase with quantitative quality metrics. The
validation system works across all providers and degrades gracefully
when embeddings aren&rsquo;t available.</p>
<p>Best of all, the architecture can accommodate new providers and capabilities
without increasing complexity. When ruby_llm adds support for a new provider,
Hyrum gets it for free. When embedding models improve, quality validation
automatically benefits. That&rsquo;s the payoff of choosing the right abstractions.</p>
<p>The project is <a href="https://github.com/grymoire7/hyrum" target="_blank" rel="noopener noreffer ">open source on GitHub</a>.
If you&rsquo;re dealing with Hyrum&rsquo;s Law in your own APIs, or you&rsquo;re just
curious about the implementation details, check it out.</p>
]]></description></item><item><title>Building a Phaser 2D grid game with Claude</title><link>https://tracyatteberry.com/posts/infection/</link><pubDate>Tue, 18 Nov 2025 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/infection/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/infection/infection_top.png" referrerpolicy="no-referrer">
            </div><h1 id="building-a-phaser-2d-grid-game-with-claude">Building a Phaser 2D grid game with Claude</h1>
<h2 id="the-experiment">The experiment</h2>
<p>This project started with a simple question: How well could an AI coding
assistant help me build a game using a framework I&rsquo;d never touched before?</p>
<p>I&rsquo;ve spent years writing JavaScript - the language was familiar territory. I
had a passing acquaintance with game development concepts. But TypeScript? I
was a novice. Phaser? Absolutely zero experience. I expected Claude to excel
with TypeScript (plenty of training data), but suspected Phaser knowledge
would be sparse.</p>
<p>The central questions:</p>
<ul>
<li>Can Claude navigate a less-common API well enough to build something real?</li>
<li>Will I understand the generated code well enough to modify and extend it?</li>
<li>What does it take to give an AI assistant enough context to be genuinely
useful rather than just generating plausible-looking code that doesn&rsquo;t
work?</li>
</ul>
<p>I decided to find out. This is the story of building &ldquo;Infection: Germs vs
White Cells&rdquo; - a turn-based grid game where players compete to dominate the
board through strategic dot placement and chain reaction explosions. More
importantly, it&rsquo;s about what I learned collaborating with AI on unfamiliar
territory.</p>
<h2 id="the-foundation-getting-started">The foundation: Getting started</h2>
<p>I chose a tech stack that mixed familiar and new territory: Phaser 3 for the
game engine, Vue 3 for UI, TypeScript for type safety, and Vite for fast
development builds. Vue would handle menus and overlays while Phaser managed
gameplay.</p>
<p>The first challenge was connecting Vue and Phaser - they&rsquo;re designed for
different purposes and don&rsquo;t naturally integrate. After researching
examples, we settled on an EventBus pattern. PhaserGame.vue became the
bridge component that initializes the Phaser game and sets up bidirectional
communication through events.</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// Phaser scene emits events to Vue
</span></span></span><span class="line"><span class="cl"><span class="nx">EventBus</span><span class="p">.</span><span class="nx">emit</span><span class="p">(</span><span class="s1">&#39;current-scene-ready&#39;</span><span class="p">,</span> <span class="k">this</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nx">EventBus</span><span class="p">.</span><span class="nx">emit</span><span class="p">(</span><span class="s1">&#39;level-completed&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">winner</span><span class="o">:</span> <span class="s1">&#39;player&#39;</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Vue listens and responds
</span></span></span><span class="line"><span class="cl"><span class="nx">EventBus</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="s1">&#39;level-completed&#39;</span><span class="p">,</span> <span class="p">(</span><span class="nx">data</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// Update UI, show victory screen, etc.
</span></span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>This pattern worked well throughout the project. Phaser handled game
logic, Vue handled UI chrome, and they stayed cleanly separated.</p>
<p>The initial gameplay came together surprisingly fast. Within the first
session, we had a working grid where players could click cells, place dots,
and see them explode when cells reached capacity. The turn system worked.
Player indicators updated correctly. The win condition detected when one
player controlled the entire board.</p>
<p>Claude was excellent at generating boilerplate and structure for familiar
patterns. The Vue-Phaser bridge? That exists in lots of projects. Basic game
loops? Common pattern. TypeScript interfaces for game state? Standard stuff.</p>
<h2 id="core-mechanics-making-it-feel-like-a-game">Core mechanics: Making it feel like a game</h2>
<p>Once the foundation worked, we focused on game feel. The core mechanic is
simple: click a cell to add a dot. When dots exceed a cell&rsquo;s capacity, the
cell explodes and distributes dots to adjacent cells. This can trigger chain
reactions that flip opponent cells to your color.</p>
<p>Cell capacity depends on position:</p>
<ul>
<li>Corner cells hold 2 dots (2 neighbors)</li>
<li>Edge cells hold 3 dots (3 neighbors)</li>
<li>Interior cells hold 4 dots (4 neighbors)</li>
<li>Blocked cells hold nothing and don&rsquo;t contribute to neighbor capacity</li>
</ul>
<p>The explosion logic needed timing. If chain reactions happened instantly,
players couldn&rsquo;t follow what was happening. We added a 300ms delay between
explosions, just enough to watch the cascade unfold without feeling sluggish.</p>
<p>Next we needed variety, different board sizes, obstacles that blocked certain
cells, and increasing difficulty. We needed levels. Rather than storing levels
in a database, we defined them in code as a linked list structure. Each level
points to the next, making navigation intuitive:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">currentLevel</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">getCurrentLevel</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">nextLevel</span> <span class="o">=</span> <span class="nx">currentLevel</span><span class="p">.</span><span class="nx">next</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">nextLevel</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">loadLevel</span><span class="p">(</span><span class="nx">nextLevel</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="k">this</span><span class="p">.</span><span class="nx">handleGameOver</span><span class="p">(</span><span class="nx">winner</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>We also added undo functionality, because clicking the wrong cell feels
terrible in a strategy game. The game tracks the last 50 moves and can roll
back the board state. This feature can also be quite useful for testing and
debugging.</p>
<h2 id="making-it-polished-ui-and-ux">Making it polished: UI and UX</h2>
<p>A working game isn&rsquo;t the same as a game that feels good to play. We spent
time on visual polish - dots that pulse when placed, smooth animations,
satisfying sound effects for placement and chain reactions.</p>
<p>The Settings scene let players configure their experience: sound effects
on/off, player colors, which level set to play. The responsive design
ensured the grid centered properly on different screen sizes.</p>
<p>The tricky part was state preservation. When players navigated from the game
to settings and back, they expected their game to still be there. This
required careful management of Phaser&rsquo;s scene lifecycle - sleep and wake
rather than destroy and recreate. We&rsquo;ll come back to this because it caused
one of our biggest bugs.</p>
<h2 id="building-ai-opponents-from-random-to-strategic">Building AI opponents: From random to strategic</h2>
<p>A game against yourself gets boring quickly. We needed a computer opponent,
but I didn&rsquo;t want to build a perfect player - that&rsquo;s no fun either. Instead,
we implemented four difficulty levels with escalating sophistication.</p>
<p><strong>Easy AI</strong> picks random valid moves. No strategy, just valid placement.</p>
<p><strong>Medium AI</strong> looks for tactical opportunities:</p>
<ul>
<li>Explode fully loaded cells (capacity reached)</li>
<li>Claim corner and edge cells (harder for opponent to capture)</li>
<li>Otherwise pick randomly</li>
</ul>
<p><strong>Hard AI</strong> adds offensive tactics:</p>
<ul>
<li>Prioritize full cells adjacent to opponent cells (capture on explosion)</li>
<li>Explode full cells next to opponent&rsquo;s full cells (trigger counter-chains)</li>
<li>Fall back to medium strategy</li>
</ul>
<p><strong>Expert AI</strong> evaluates positional advantage using &ldquo;ullage&rdquo; (remaining
capacity). It seeks cells where adding a dot gives advantage over all
adjacent opponent cells, forcing the opponent into difficult positions.</p>
<p>Each level specifies its AI difficulty, so players face escalating challenge
as they progress through a level set.</p>
<p>The development approach: start simple, iterate to complexity. We built the
dumbest thing that could work (random moves), then made it smarter
incrementally based on playtesting feedback.</p>
<h2 id="architecture-evolution-when-code-gets-messy">Architecture evolution: When code gets messy</h2>
<p>Here&rsquo;s where I made my first major mistake: I put too much in Game.ts.
In self-defense, I didn&rsquo;t know yes what the architecture <em>should</em> look like yet.
So I deferred those decisions until later by putting everything in one place.</p>
<p>At first, this seemed fine. The game logic lived in the game scene. Makes
sense, right? But as features accumulated, Game.ts grew to over 1000 lines.
It handled grid creation, cell capacity calculations, explosion logic, AI
moves, UI updates, state persistence, settings management, and level
progression. Reading it required holding too many concepts in your head at
once.</p>
<p>The pain became obvious when bugs appeared. Tracking down a state persistence
bug meant wading through explosion logic and UI code. Fixing the play order
required understanding grid creation. Everything touched everything.</p>
<p>We needed a separation of concerns. Not for &ldquo;clean code&rdquo; aesthetics, but
because the cognitive complexity made changes risky and debugging slow.</p>
<p>The refactoring happened incrementally, driven by specific pain points:</p>
<p><strong>GameStateManager</strong> emerged when state bugs appeared. We needed one clear
place responsible for saving and loading game state to Phaser&rsquo;s registry,
handling undo history, and tracking level progression.</p>
<p><strong>GridManager</strong> split out when grid logic got complex. Cell capacity
calculations, blocked cell handling, hover states, and visual styling didn&rsquo;t
belong mixed with game logic.</p>
<p><strong>GameUIManager</strong> formed when UI updates scattered throughout the code. One
change to the player indicator required hunting through multiple methods.
Now UI creation and updates live in one place.</p>
<p><strong>SettingsManager</strong> centralized the synchronization between localStorage and
Phaser&rsquo;s registry. Settings read priority became explicit: registry first,
then localStorage, then defaults.</p>
<p><strong>BoardStateManager</strong> extracted the core game logic - explosion mechanics,
chain reactions, win condition detection. This became the pure game engine,
separate from Phaser rendering concerns.</p>
<p>Each refactoring happened when the pain became clear, not as a planned
&ldquo;rewrite day.&rdquo; We didn&rsquo;t wait for the perfect time to refactor. We
refactored when the current structure made the next feature difficult.</p>
<p>The linked list structure for levels proved elegant. Rather than tracking
level indices and bounds-checking arrays, levels just know their next level.
The code reads naturally: <code>if (currentLevel.isLast())</code> instead of <code>if (currentLevelIndex &gt;= levels.length - 1)</code>.</p>
<p>After refactoring, most manager classes stayed under 500 lines with single,
clear responsibilities. The Game scene itself still exceeds 800 lines (it
orchestrates all the managers and handles complex scene lifecycle), but the
cognitive load dropped dramatically. You can now understand GridManager
without knowing anything about state persistence or AI strategy.</p>
<h2 id="the-testing-awakening">The testing awakening</h2>
<p>I need to confess something: I allowed a lot of code to be written before writing
tests.</p>
<p>My rationale seemed sound at the time. I was learning Phaser&rsquo;s architecture
and didn&rsquo;t want to constantly rewrite tests as I figured out the right
patterns. Better to get something working first, then add tests once the
architecture stabilized.</p>
<p>This was expensive.</p>
<p>Without tests, every refactoring risked breaking something. I&rsquo;d extract
GameStateManager and then manually click through the entire game to verify
level progression still worked. I&rsquo;d modify explosion logic and hand-test
edge cases by setting up specific board states. Bugs appeared, got fixed,
then reappeared weeks later because nothing prevented regression.</p>
<p>The wake-up call came during a refactoring that broke the undo system in a
subtle way. The game worked for new games, but loading a saved game with
undo history crashed. I&rsquo;d fixed this bug before. Now it was back.</p>
<p>We needed comprehensive test coverage.</p>
<p>Working with Claude, we built a four-phase testing plan:</p>
<p><strong>Phase 1: Core data structures</strong> (69 tests)</p>
<ul>
<li>Level class: linked list navigation, property access, last level detection</li>
<li>LevelSet class: level management, traversal, bounds checking</li>
</ul>
<p><strong>Phase 2: Manager classes</strong> (196 tests)</p>
<ul>
<li>SettingsManager: localStorage/registry sync, defaults, read priority</li>
<li>GameStateManager: save/load, undo/redo, move history limits</li>
<li>LevelSetManager: loading definitions, level set switching</li>
<li>BoardStateManager: game logic, explosions, win conditions</li>
</ul>
<p><strong>Phase 3: Game logic</strong> (88 tests)</p>
<ul>
<li>GridManager: cell capacity, blocked cells, hover states</li>
<li>ComputerPlayer: all four difficulty levels, move validation</li>
</ul>
<p><strong>Phase 4: UI layer</strong> (49 tests)</p>
<ul>
<li>GameUIManager: element creation, updates, positioning</li>
</ul>
<p>We used Vitest because it&rsquo;s fast, has excellent TypeScript support, and
provides a clean testing API. Tests lived next to their source files:
<code>GridManager.ts</code> and <code>GridManager.test.ts</code> in the same directory.</p>
<p>The test suite currently has <strong>514 tests across 20 test files</strong>, and they
run in about 1.2 seconds. Fast enough to run on every save during
development.</p>
<p>Writing tests after the fact taught me something: test-driven development
exists for good reasons. The tests we wrote exposed edge cases we&rsquo;d never
considered. They caught bugs that would have appeared weeks later. They made
refactoring safe instead of terrifying.</p>
<p>If I started this project over, I&rsquo;d write tests earlier. Not because
tests are &ldquo;best practice,&rdquo; but because they would have saved me days of
debugging time.</p>
<h2 id="-key-challenges-and-debugging-victories">🪲 Key challenges and debugging victories</h2>
<p><em>The real learning happened when things broke. Here are four bugs that
taught me the most about Phaser, systematic debugging, and AI collaboration.</em></p>
<h3 id="challenge-1-the-settings-scene-reset-bug">Challenge 1: The settings scene reset bug</h3>
<p><strong>Symptom:</strong> Navigate to Settings, change nothing, click Back. The game
resets to the first level. Your in-progress game vanishes.</p>
<p><strong>First instinct:</strong> The state isn&rsquo;t being saved. We added logging to
GameStateManager. The state was saving perfectly. The state was loading
correctly too. What?</p>
<p><strong>Root cause:</strong> We were using <code>scene.start()</code> to transition between Game and
Settings scenes. This method destroys the current scene and creates a fresh
instance of the target scene. When returning to Game, we got a brand new
Game scene that ran its <code>create()</code> method, which loaded the first level by
default.</p>
<p><strong>The fix:</strong> Phaser scenes have a lifecycle: <code>create()</code> runs once when the
scene is first instantiated. <code>wake()</code> runs when a sleeping scene becomes
active again. We needed:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// In Game scene
</span></span></span><span class="line"><span class="cl"><span class="nx">navigateToSettings() {</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">scene</span><span class="p">.</span><span class="nx">sleep</span><span class="p">();</span>  <span class="c1">// Not scene.start()
</span></span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">scene</span><span class="p">.</span><span class="nx">launch</span><span class="p">(</span><span class="s1">&#39;Settings&#39;</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></span><span class="line"><span class="cl"><span class="c1">// In Settings scene
</span></span></span><span class="line"><span class="cl"><span class="nx">goBack() {</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">scene</span><span class="p">.</span><span class="nx">stop</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">scene</span><span class="p">.</span><span class="nx">wake</span><span class="p">(</span><span class="s1">&#39;Game&#39;</span><span class="p">);</span>  <span class="c1">// Wake the sleeping scene
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>We also added a <code>settingsDirty</code> flag. If settings actually changed, the Game
scene&rsquo;s <code>wake()</code> method reloads them. Otherwise, it just resumes.</p>
<p><strong>Lesson:</strong> Understanding framework lifecycles matters. Claude knew the
general pattern but didn&rsquo;t initially suggest wake/sleep because the Phaser
API wasn&rsquo;t in its training data as heavily. Providing links to current
Phaser 3.90 documentation helped tremendously. Without docs, Claude would
continue to guess based on older API versions, wasting time.</p>
<h3 id="challenge-2-level-progression-bug">Challenge 2: Level progression bug</h3>
<p><strong>Symptom:</strong> Complete the first level, click &ldquo;Next Level.&rdquo; You see the first
level again instead of level 2.</p>
<p><strong>The investigation:</strong> We added logging to track what level was being loaded:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`[GameStateManager] Saved state:
</span></span></span><span class="line"><span class="cl"><span class="sb">  </span><span class="si">${</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">boardState</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`[BoardStateManager] Setting state:
</span></span></span><span class="line"><span class="cl"><span class="sb">  </span><span class="si">${</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">boardState</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span></span></span></code></pre></div></div>
<p>The logs revealed the issue: we were saving an empty <code>boardState</code> to the
registry, which triggered the &ldquo;new game&rdquo; code path that loaded level 1.</p>
<p><strong>Root cause:</strong> The level completion logic set a <code>loadNextLevel</code> flag, but the
state persistence logic also saw an empty board and saved it. This was a race
condition where both actions happened simultaneously, and the empty state won.</p>
<p><strong>The fix:</strong> Prioritize the <code>loadNextLevel</code> flag. Check it before looking at
board state:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">wake() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">savedState</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">stateManager</span><span class="p">.</span><span class="nx">loadFromRegistry</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">savedState</span><span class="o">?</span><span class="p">.</span><span class="nx">loadNextLevel</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">nextLevel</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">currentLevel</span><span class="p">.</span><span class="nx">next</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">nextLevel</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">this</span><span class="p">.</span><span class="nx">loadLevel</span><span class="p">(</span><span class="nx">nextLevel</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span><span class="p">;</span>  <span class="c1">// Exit early
</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="c1">// Otherwise restore board state
</span></span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">savedState</span><span class="o">?</span><span class="p">.</span><span class="nx">boardState</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">this</span><span class="p">.</span><span class="nx">restoreBoardState</span><span class="p">(</span><span class="nx">savedState</span><span class="p">.</span><span class="nx">boardState</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><strong>Lesson:</strong> The linked list structure actually helped here. The code <code>if (nextLevel)</code> makes it obvious we&rsquo;re checking if a next level exists. With
array indices, we&rsquo;d have <code>if (currentLevelIndex + 1 &lt; levels.length)</code>, which
is more error-prone.</p>
<h3 id="challenge-3-level-set-changes-not-taking-effect">Challenge 3: Level set changes not taking effect</h3>
<p><strong>Symptom:</strong> User selects a different level set in Settings, clicks &ldquo;Play
Game.&rdquo; They see the old level set instead.</p>
<p><strong>Root cause:</strong> The Game scene wasn&rsquo;t checking if settings changed while it
was sleeping. It would wake up and continue with the old LevelSetManager.</p>
<p><strong>The fix:</strong> The <code>settingsDirty</code> flag from Challenge 1 solved this too. When
settings change, the flag gets set. On wake, if the flag is set, reload all
settings:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">wake() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">settingsDirty</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">game</span><span class="p">.</span><span class="nx">registry</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s1">&#39;settingsDirty&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">settingsDirty</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">this</span><span class="p">.</span><span class="nx">reloadAllSettings</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">    <span class="k">this</span><span class="p">.</span><span class="nx">game</span><span class="p">.</span><span class="nx">registry</span><span class="p">.</span><span class="kr">set</span><span class="p">(</span><span class="s1">&#39;settingsDirty&#39;</span><span class="p">,</span> <span class="kc">false</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></span><span class="line"><span class="cl">  <span class="c1">// ... rest of wake logic
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>We also needed defensive logic. What if the user changed both player color
AND level set? Both changes needed to take effect together, not sequentially
with potential state corruption between them.</p>
<p><strong>Lesson:</strong> State synchronization across scene transitions requires explicit
change detection. Don&rsquo;t assume data hasn&rsquo;t changed while your scene slept.</p>
<h3 id="challenge-4-memory-leaks-from-event-listeners">Challenge 4: Memory leaks from event listeners</h3>
<p><strong>Symptom:</strong> During manual testing, I noticed the browser memory footprint
growing as I transitioned between scenes repeatedly. Something was leaking.</p>
<p><strong>The investigation:</strong> We added event listener counting to each scene:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">shutdown() {</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`[</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="kr">constructor</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">] Listeners before cleanup:
</span></span></span><span class="line"><span class="cl"><span class="sb">    </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">events</span><span class="p">.</span><span class="nx">listenerCount</span><span class="p">(</span><span class="s1">&#39;pointerdown&#39;</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">cleanupEventListeners</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`[</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="kr">constructor</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">] Listeners after cleanup:
</span></span></span><span class="line"><span class="cl"><span class="sb">    </span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">events</span><span class="p">.</span><span class="nx">listenerCount</span><span class="p">(</span><span class="s1">&#39;pointerdown&#39;</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>The counts kept growing. Event listeners weren&rsquo;t being cleaned up on scene
transitions.</p>
<p><strong>The fix:</strong> We added explicit cleanup methods to every scene using TDD.
First, write a test that verifies listeners are removed:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">it</span><span class="p">(</span><span class="s1">&#39;should clean up all button event listeners on shutdown&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">scene</span><span class="p">.</span><span class="nx">create</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">beforeCount</span> <span class="o">=</span> <span class="nx">scene</span><span class="p">.</span><span class="nx">events</span><span class="p">.</span><span class="nx">listenerCount</span><span class="p">(</span><span class="s1">&#39;pointerdown&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">beforeCount</span><span class="p">).</span><span class="nx">toBeGreaterThan</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="nx">scene</span><span class="p">.</span><span class="nx">cleanupButtonListeners</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">afterCount</span> <span class="o">=</span> <span class="nx">scene</span><span class="p">.</span><span class="nx">events</span><span class="p">.</span><span class="nx">listenerCount</span><span class="p">(</span><span class="s1">&#39;pointerdown&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">afterCount</span><span class="p">).</span><span class="nx">toBe</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>Then implement cleanup:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">shutdown() {</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">cleanupButtonListeners</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">cleanupGridListeners</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="nx">cleanupUIListeners</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>We built a live testing dashboard (<code>npm run test:live</code>) that shows real-time
event listener counts during rapid scene transitions. You can watch the
numbers and verify they don&rsquo;t accumulate.</p>
<p><em>[Screenshot: Live testing dashboard showing event listener metrics would go
here]</em></p>
<p><strong>Lesson:</strong> Building testing infrastructure surfaced issues we didn&rsquo;t know
existed. The process of creating the dashboard forced us to think about how
to measure cleanup, which led us to Phaser&rsquo;s <code>listenerCount()</code> API and
revealed leaks throughout the codebase. We now have 95% test automation for
event cleanup validation and zero known memory leaks.</p>
<p><strong>Common thread:</strong> Each bug revealed itself through evidence gathering
(logging, instrumentation) rather than guessing. This pattern became the
foundation for our systematic debugging approach.</p>
<h2 id="the-debugging-discipline">The debugging discipline</h2>
<p>These four challenges revealed something important: guessing doesn&rsquo;t scale,
even for AI.</p>
<p>Early in the project, Claude would hit a bug and immediately suggest a fix.
Didn&rsquo;t work? Try another. Still broken? Try a third. This guess-and-check
thrashing wasted hours and often made problems worse.</p>
<p>I had access to Jesse Vincent&rsquo;s systematic debugging &ldquo;superpowers&rdquo; (think of
them as process discipline plugins for Claude). They just weren&rsquo;t being
enforced. After enough frustration, I made the systematic debugging
superpower mandatory in CLAUDE.md - the project documentation file that
guides Claude&rsquo;s behavior. The protocol is straightforward:</p>
<ol>
<li><strong>Gather evidence first</strong> - Add logging to understand what&rsquo;s actually
happening, not what you think is happening</li>
<li><strong>Analyze patterns</strong> - Compare working vs broken implementations</li>
<li><strong>Test single hypotheses</strong> - Make one targeted change to test a theory</li>
<li><strong>Fix root causes</strong> - Address the actual problem, not symptoms</li>
</ol>
<p>It also includes this note: &ldquo;Systematic debugging is 5x faster than
guess-and-check thrashing.&rdquo;</p>
<p>Before adding this additional directive, Claude would often suggest fixes immediately:
&ldquo;Try changing this API call&rdquo; or &ldquo;Maybe add this flag.&rdquo; After adding it,
Claude would first suggest adding instrumentation: &ldquo;Let&rsquo;s add logging to see
what values we&rsquo;re actually getting.&rdquo;</p>
<p>The difference was dramatic. Bugs that previously took hours to solve took
30 minutes. We stopped creating bugs while fixing bugs.</p>
<p>Full disclosure: I&rsquo;m a senior developer and I knew better. But curiosity got
the best of me. One of my goals was understanding AI behavior patterns to
collaborate more effectively on future projects. I wanted to see if Claude
could guess its way to solutions.</p>
<p>The answer: Sometimes, but unreliably. With sufficient context, guessing
(or pattern recognition that looks like guessing) often worked. On
unfamiliar frameworks like Phaser, it usually failed.</p>
<p><strong>Key insights:</strong></p>
<ul>
<li>For Claude: Evidence before action. Always.</li>
<li>For me: Enforce systematic approaches through CLAUDE.md, don&rsquo;t rely on AI
self-discipline.</li>
</ul>
<p>For scene lifecycle bugs, we added comprehensive logging:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-typescript">
        <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-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">create() {</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`[</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="kr">constructor</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">] ===== SCENE CREATE START =====`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ... scene creation code ...
</span></span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`[</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="kr">constructor</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">] ===== SCENE CREATE END =====`</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></span><span class="line"><span class="cl"><span class="nx">wake() {</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`[</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="kr">constructor</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">] ===== SCENE WAKE START =====`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">settings</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">game</span><span class="p">.</span><span class="nx">registry</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s1">&#39;settingsDirty&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`[</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="kr">constructor</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">] Settings dirty: </span><span class="si">${</span><span class="nx">settings</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ... wake logic ...
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>This logging made scene transitions visible. We could see exactly when
<code>create()</code> ran vs <code>wake()</code>, what data each method received, and what order
operations occurred in. Bugs became obvious instead of mysterious.</p>
<h2 id="working-with-claude-what-worked-what-didnt">Working with Claude: What worked, what didn&rsquo;t</h2>
<p><em>Hiring managers care about productivity and code quality. Here&rsquo;s an honest
assessment of where AI helped, where it struggled, and what that means for
development teams.</em></p>
<h3 id="what-worked-well">What worked well</h3>
<p><strong>Boilerplate and structure:</strong> Claude excels at generating TypeScript
interfaces, class structures, and common patterns. Need a manager class with
standard CRUD operations? Claude writes it in seconds.</p>
<p><strong>Pattern recognition:</strong> &ldquo;This looks like the command pattern&rdquo; or &ldquo;This is
similar to the observer pattern we used for events&rdquo; - Claude connects new
problems to solved problems effectively.</p>
<p><strong>Architectural improvements:</strong> When I recognized Game.ts had grown too
large at 1000+ lines, Claude suggested extraction patterns that made sense.
The refactoring strategies were sound once the problem was identified.</p>
<p><strong>Test writing:</strong> Once we established a pattern for one test file, Claude
could generate similar tests for other classes. The 514 tests would have
taken weeks to write manually.</p>
<p><strong>Systematic debugging:</strong> Once convinced to use the systematic debugging superpower,
Claude followed it reliably. This helped enormously and saved a ton of time.</p>
<h3 id="what-required-guidance">What required guidance</h3>
<p><strong>Phaser API specifics:</strong> This was the biggest challenge. Claude&rsquo;s training
data apparently has much less Phaser content than TypeScript or Vue. It
would suggest API calls that sounded plausible but didn&rsquo;t exist, or use
patterns from older Phaser versions.</p>
<p>The solution: Provide links to current Phaser 3.90 documentation. When I
sent Claude snippets from official docs, suggestions became more accurate.
Without docs, Claude would guess, and guessing wasted time.</p>
<p><strong>Project-specific architecture decisions:</strong> Claude couldn&rsquo;t decide whether
to use localStorage vs Phaser&rsquo;s registry, or when to extract a manager class
vs keep code together. These decisions required human judgment based on
project context. Clearer instructions in CLAUDE.md helped, but some decisions
still needed human guidance.</p>
<p><strong>Refactoring timing:</strong> Claude would sometimes suggest refactoring when we
needed to ship, or suggest shipping when the code really needed cleanup. The
&ldquo;when&rdquo; required human intuition.</p>
<p><strong>Testing discipline:</strong> Without explicit guidance, Claude would tend to
neglect testing. It would happily write feature after feature without
suggesting tests. The comprehensive test suite only happened because I
explicitly requested it and then added requirements to CLAUDE.md.</p>
<p><strong>Claiming victory too early:</strong> Claude would repeatedly declare a bug fixed
after a potential fix. Before any verification it was ready to mark the item
as complete and move on. It needed reminders to verify that changes actually worked.</p>
<h3 id="the-claudemd-evolution">The CLAUDE.md evolution</h3>
<p>CLAUDE.md started as a basic README: project structure, how to run tests,
basic architecture notes.</p>
<p>It evolved into the project brain - a comprehensive guide that overrides
Claude&rsquo;s default behavior:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-markdown">
        <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-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## 🚨 MANDATORY DEBUGGING PROTOCOL
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gs">**FOR ANY TECHNICAL ISSUE - ALWAYS use systematic debugging**</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gs">**FORBIDDEN PATTERNS (cause more bugs than they fix):**</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> &#34;Quick fixes&#34; and guesswork - <span class="gs">**STRICTLY PROHIBITED**</span>
</span></span><span class="line"><span class="cl"><span class="k">-</span> Trying random API calls without understanding root cause
</span></span><span class="line"><span class="cl">- Making multiple changes at once</span></span></code></pre></div></div>
<p>Before this protocol existed, Claude would get caught in guessing loops. Try
a fix, doesn&rsquo;t work, try another, still broken, try a third. The mandatory
protocol broke this pattern.</p>
<p>We documented every resolved bug: symptoms, root causes, fixes. When similar
issues appeared later, the documentation provided patterns to recognize.</p>
<p>The key realization: Documentation is bidirectional. I taught Claude about
the project, and the process of explaining things to Claude clarified my own
understanding. Writing clear instructions forced me to think clearly about
solutions. Some of the lessons learned here will be added to the global
<code>~/.claude/CLAUDE.md</code> file for future projects.</p>
<p><strong>Bottom line:</strong> AI assistance works best as a partnership. The human brings
judgment, context, and architectural vision. The AI brings speed,
consistency, and tireless execution of well-defined tasks. Neither replaces
the other.</p>
<h2 id="lessons-learned">Lessons learned</h2>
<p><em>Here&rsquo;s what I&rsquo;d tell my past self, or anyone starting a similar project.</em></p>
<h3 id="technical-lessons">Technical lessons</h3>
<p><strong>1. Test early, test often</strong></p>
<p>Writing tests after code cost significant rework time. Tests exposed edge
cases we&rsquo;d never considered. They caught regressions before they shipped.
They made refactoring safe.</p>
<p>If I restarted this project, tests would come first. Not as &ldquo;best practice&rdquo;
dogma, but as a practical time-saving tool.</p>
<p><strong>2. Scene lifecycle matters</strong></p>
<p>Understanding <code>create()</code> vs <code>wake()</code> vs <code>sleep()</code> vs <code>shutdown()</code> is
critical in Phaser. The wrong method causes subtle bugs. The right method
makes everything work.</p>
<p><strong>3. Registry over localStorage</strong></p>
<p>We used Phaser&rsquo;s registry as the single source of truth for runtime state,
with localStorage only for persistent settings. This prevented
synchronization bugs between storage systems.</p>
<p><strong>4. Separation of concerns reduces cognitive load</strong></p>
<p>This isn&rsquo;t about &ldquo;clean code&rdquo; aesthetics. When Game.ts exceeded 1000 lines,
making changes became risky because every change could affect multiple
unrelated features. After extracting manager classes, each file became
understandable in isolation.</p>
<p><strong>5. Linked lists for sequential navigation</strong></p>
<p>The linked list structure for levels made code readable: <code>currentLevel. next()</code> instead of array index math. It also made the progression concept
explicit in the data structure.</p>
<p><strong>6. Event cleanup is not optional</strong></p>
<p>Memory leaks accumulate silently. Without explicit cleanup and testing, the
browser&rsquo;s memory footprint grows. Players might not notice on first play,
but the leak still exists.</p>
<h3 id="ai-collaboration-lessons">AI collaboration lessons</h3>
<p><strong>1. Documentation is bidirectional</strong></p>
<p>Teaching Claude about the project clarified my own thinking. Writing
instructions forced clear problem statements. The CLAUDE.md file became as
valuable for me as for the AI.</p>
<p><strong>2. Systematic approaches scale</strong></p>
<p>Ad-hoc debugging doesn&rsquo;t work on complex projects. The mandatory debugging
protocol saved enormous amounts of time by preventing guess-and-check
thrashing.</p>
<p><strong>3. Start simple, iterate to complexity</strong></p>
<p>We didn&rsquo;t architect everything perfectly from day one. We built the
simplest thing that could work (random AI, basic grid) then made it more
sophisticated incrementally. This approach worked much better than trying to
design the perfect system upfront.</p>
<p><strong>4. Context files matter</strong></p>
<p>CLAUDE.md became the project brain. It captured architectural decisions,
debugging patterns, resolved bugs, and mandatory workflows. Without it,
every conversation started from zero.</p>
<p><strong>5. AI is better with constraints</strong></p>
<p>Claude works best with clear protocols and explicit constraints. &ldquo;Debug this
bug&rdquo; leads to guessing. &ldquo;Follow the systematic debugging protocol to
investigate this bug&rdquo; leads to instrumentation and evidence gathering.</p>
<h3 id="process-lessons">Process lessons</h3>
<p><strong>1. Git commit messages tell the story</strong></p>
<p>Using conventional commits (feat:, fix:, refactor:, test:) made history
searchable. When debugging the level progression bug, searching for &ldquo;fix:
level&rdquo; immediately found commit <code>0eb8769</code>. When preparing this blog post,
running <code>git log --oneline --reverse</code> showed the project evolution clearly.</p>
<p><strong>2. Refactoring is continuous</strong></p>
<p>We didn&rsquo;t schedule &ldquo;refactoring week.&rdquo; We refactored when current structure
made the next feature difficult:</p>
<ul>
<li>Game.ts hit 1000+ lines → extracted GameStateManager</li>
<li>State bugs appeared → extracted BoardStateManager</li>
<li>Grid logic got complex → extracted GridManager</li>
<li>UI updates scattered → extracted GameUIManager</li>
</ul>
<p>Each refactoring addressed immediate pain, not theoretical future problems.</p>
<p><strong>3. Build testing infrastructure</strong></p>
<p>The live testing dashboard (<code>npm run test:live</code>) seemed like overkill for a
simple game. But building it forced us to think about how to measure event
cleanup, which revealed the <code>listenerCount()</code> API, which exposed leaks we
didn&rsquo;t know existed. The infrastructure paid for itself.</p>
<p><strong>4. Document gotchas immediately</strong></p>
<p>Every resolved bug went into CLAUDE.md immediately. The documentation
prevented the same bug from reappearing and provided patterns for similar
issues. Future me thanked past me repeatedly.</p>
<h2 id="the-final-numbers">The final numbers</h2>
<p><em>What AI-assisted development produced:</em></p>
<p><strong>Code quality metrics:</strong></p>
<ul>
<li><strong>514 tests</strong> across 20 test files (started with 0, grew through 4-phase
testing plan)</li>
<li><strong>95% test automation</strong> for event cleanup validation</li>
<li><strong>~1.2 second</strong> test suite execution time (fast enough to run on every
save)</li>
<li><strong>Zero known memory leaks</strong> after systematic cleanup and monitoring</li>
</ul>
<p><strong>Architecture:</strong></p>
<ul>
<li><strong>9 core manager classes</strong> handling distinct concerns (extracted from
monolithic Game.ts)</li>
<li><strong>10 Phaser scenes</strong> managing game flow (Boot → Preloader → Splash →
MainMenu → Game/About/Tutorial/Settings → LevelOver → GameOver)</li>
<li><strong>4 AI difficulty levels</strong> implementing escalating strategic sophistication</li>
</ul>
<p><strong>Content:</strong></p>
<ul>
<li><strong>Multiple level sets</strong> with 5-7 levels each</li>
<li><strong>Variable board sizes</strong> and blocked cell patterns for strategic variety</li>
<li><strong>100+ commits</strong> documenting the evolution with conventional commit format</li>
</ul>
<p>The game is playable, maintainable, and well-tested. More importantly, the
codebase is understandable. A developer new to the project could read
GridManager without knowing anything about AI strategy, or modify explosion
logic without understanding state persistence. That&rsquo;s what separation of
concerns actually buys you.</p>
<h2 id="was-it-worth-it">Was it worth it?</h2>
<p>Absolutely.</p>
<p>I built a working game using a framework I&rsquo;d never used, and the code is
maintainable enough that I&rsquo;d be comfortable handing it to another developer.
That&rsquo;s the real test.</p>
<p>Claude bridged knowledge gaps effectively where it had training data
(TypeScript, design patterns). Where it lacked context (Phaser specifics,
project architecture), providing documentation and clear constraints made it
productive.</p>
<p>The discipline of testing and documentation paid dividends. The 514-test
suite catches regressions before they reach production. The CLAUDE.md file
captures institutional knowledge that would otherwise live only in my head.
The systematic debugging protocol prevents guess-and-check thrashing that
wastes hours.</p>
<p><strong>Key insight:</strong> AI works best with clear constraints and feedback. Without
the debugging protocol, Claude would guess. With it, Claude would gather
evidence. Without test requirements, Claude would skip tests. With
requirements, it wrote comprehensive coverage.</p>
<p>The game is playable and reasonably fun. The AI provides genuine challenge.
The animations feel responsive. Is the architecture perfect? Honestly, I
don&rsquo;t know. But it&rsquo;s good enough to ship, iterate on, and extend - which is
the point.</p>
<p>Would I do it again? Absolutely, but I&rsquo;d establish testing discipline
earlier and encode systematic approaches in CLAUDE.md from day one.</p>
<h2 id="try-it-yourself">Try it yourself</h2>
<p>Want to see the results or dig into the implementation?</p>
<ul>
<li><strong><a href="https://magicbydesign.com/infection" target="_blank" rel="noopener noreffer ">Play the game</a></strong> - Try it in your
browser (no installation required)</li>
<li><strong><a href="https://github.com/grymoire7/infection" target="_blank" rel="noopener noreffer ">View the source</a></strong> - Explore
the code with full commit history</li>
<li><strong><a href="https://github.com/grymoire7/infection/blob/main/CLAUDE.md" target="_blank" rel="noopener noreffer ">Read CLAUDE.md</a></strong>
<ul>
<li>See the &ldquo;project brain&rdquo; that guided development decisions</li>
</ul>
</li>
</ul>
<p>If you&rsquo;re considering AI-assisted development, especially with unfamiliar
frameworks, here&rsquo;s what I learned works:</p>
<ul>
<li>Provide current documentation when the AI lacks training data (links to
official docs beat guessing every time)</li>
<li>Establish systematic approaches early (debugging protocols, testing
requirements)</li>
<li>Write tests as you go, not retrospectively</li>
<li>Use a context file (CLAUDE.md) to capture architecture decisions and
patterns</li>
<li>Expect to teach the AI your project specifics - documentation is
bidirectional</li>
</ul>
<p><strong>The partnership model:</strong> You bring judgment, architectural vision, and
domain knowledge. AI brings speed, consistency, and tireless execution of
well-defined tasks. Neither replaces the other, but together they can tackle
unfamiliar territory effectively.</p>
<p>Now go build something.</p>
]]></description></item><item><title>Building a Document Q&amp;A System with Rails 8, SQLite-vec, and OpenAI</title><link>https://tracyatteberry.com/posts/ragtime/</link><pubDate>Fri, 14 Nov 2025 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/ragtime/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/ragtime/password_access.png" referrerpolicy="no-referrer">
            </div><h1 id="building-a-document-qa-system-with-rails-8-sqlite-vec-and-openai">Building a document Q&amp;A system with Rails 8, SQLite-vec, and OpenAI</h1>
<p>Every company wants to plug AI into their proprietary data. But here&rsquo;s the
thing—it&rsquo;s not just about calling an API. The real challenge is building
systems that actually work reliably and are maintainable in the real world.</p>
<p>So I built Ragtime. Think of it as my playground for figuring out how to build
modern AI applications the right way. It&rsquo;s a document Q&amp;A system where you can
upload PDFs, Word docs, text files, and Markdown, then ask questions and get
answers with actual source citations.</p>
<p>What I really wanted to show wasn&rsquo;t just that I can build an AI app—anyone can
do that these days. I wanted to demonstrate how a senior engineer thinks about
building these systems from the ground up, making smart trade-offs and avoiding
common pitfalls.</p>
<figure style="margin-top: 15px">
  
  <figcaption>Password protected demo login screen</figcaption>
</figure>
<h2 id="architecture-overview">Architecture overview</h2>
<p>So what does Ragtime actually look like under the hood? At its core, it&rsquo;s a RAG
(Retrieval-Augmented Generation) system—fancy talk for &ldquo;find relevant material,
then use it to answer questions.&rdquo;</p>
<div class="mermaid" id="id-1"></div>
<p>Here&rsquo;s how it works: you upload a document, the system pulls out the text,
chops it into smart chunks, turns those chunks into mathematical vectors (more
on that in a bit), and then when you ask a question, it finds the most relevant
chunks and uses them to generate an answer with citations. Simple, right? Well,
there are some fun challenges along the way.</p>
<h3 id="why-this-architecture">Why this architecture?</h3>
<p>Let me tell you why I picked this stack. Rails 8 just clicked for the backend
because it&rsquo;s got some really nice improvements for modern apps—better API
support out of the box, Solid Queue built right in (no more juggling separate
worker processes), and some solid performance tweaks&ndash;especially for SQLite.
Going API-only gave me this clean separation between frontend and backend,
which makes everything easier to maintain and reason about.</p>
<p>For the frontend, Vue.js 3 with the Composition API just feels right for chat
interfaces. You get way better state management than server-rendered options,
and the component-based architecture makes complex UI stuff like real-time chat
and interactive citations so much easier to build.</p>
<h2 id="the-vector-storage-decision-sqlite--sqlite-vec">The vector storage decision: SQLite + sqlite-vec</h2>
<p>This was probably the biggest technical decision I had to make—how to store all
those vector embeddings. I looked at three options:</p>
<ol>
<li><strong>PostgreSQL + pgvector</strong>: This is what everybody uses in production</li>
<li><strong>Dedicated vector databases</strong> (like Pinecone or Weaviate): The fancy specialized solutions</li>
<li><strong>SQLite + sqlite-vec</strong>: The simple, &ldquo;just make it work&rdquo; approach</li>
</ol>
<p>I went with SQLite + sqlite-vec, and I have to be honest—it was way more
complicated than I expected. Here&rsquo;s my thinking: when you&rsquo;re trying to show
engineering competence, deployment simplicity matters more than theoretical
scalability. But sqlite-vec turned out to be a significant engineering challenge
in its own right.</p>
<p>The extension doesn&rsquo;t just work out of the box. I had to:</p>
<ul>
<li>Bootstrap it manually in the docker-entrypoint for production</li>
<li>Load it programmatically in code rather than declaring it in database.yml</li>
<li>Add special verification in health check endpoints because if it fails to load,
the entire RAG functionality breaks</li>
<li>Turn off transactional fixtures in tests and create custom test support code</li>
</ul>
<p>While SQLite gives you that single-file, zero-dependency promise, getting
sqlite-vec to work reliably required significant effort. This really highlights a
key engineering principle: sometimes the &ldquo;simple&rdquo; choice brings its own complex
challenges that you need to account for.</p>
<h2 id="document-processing-pipeline">Document processing pipeline</h2>
<p>Building a document processing pipeline that actually works reliably meant
solving some pretty fun challenges:</p>
<h3 id="challenge-1-getting-text-out-of-different-file-types">Challenge 1: Getting text out of different file types</h3>
<p>First problem: documents come in all shapes and sizes. PDFs, Word docs, plain
text, Markdown—each one needs its own special handling trick. I ended up
building a <code>TextExtractor</code> service that&rsquo;s basically a Swiss Army knife for file
formats. It knows how to handle each type, and when things go wrong (which they
always do), it fails gracefully and tells you what happened.</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"># app/services/document_processing/text_extractor.rb</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DocumentProcessing</span><span class="o">::</span><span class="no">TextExtractor</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">extract</span><span class="p">(</span><span class="n">file</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">file</span><span class="o">.</span><span class="n">content_type</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s1">&#39;application/pdf&#39;</span>
</span></span><span class="line"><span class="cl">      <span class="no">PdfReader</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">file</span><span class="p">)</span><span class="o">.</span><span class="n">extract_text</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s1">&#39;application/vnd.openxmlformats-officedocument.wordprocessingml.document&#39;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DocxReader</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">file</span><span class="p">)</span><span class="o">.</span><span class="n">extract_text</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s1">&#39;text/plain&#39;</span><span class="p">,</span> <span class="s1">&#39;text/markdown&#39;</span>
</span></span><span class="line"><span class="cl">      <span class="n">file</span><span class="o">.</span><span class="n">download</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">raise</span> <span class="s2">&#34;Unsupported file type: </span><span class="si">#{</span><span class="n">file</span><span class="o">.</span><span class="n">content_type</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">rescue</span> <span class="o">=&gt;</span> <span class="n">error</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">error</span> <span class="s2">&#34;Text extraction failed: </span><span class="si">#{</span><span class="n">error</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="no">DocumentProcessing</span><span class="o">::</span><span class="no">ExtractionError</span><span class="p">,</span> <span class="s2">&#34;Failed to extract text from document&#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>
<h3 id="challenge-2-splitting-text-intelligently">Challenge 2: Splitting text intelligently</h3>
<p>Getting the chunking right is crucial for RAG systems. The key challenge is
maintaining context—chunks need to be small enough to be relevant but large
enough to retain meaning.</p>
<p>I implemented a <code>TextChunker</code> that creates 800-token chunks with 200 tokens of
overlap, which is a well-established pattern in RAG systems. The overlap
ensures related context spans multiple chunks, and the system respects
paragraph boundaries to maintain semantic coherence.</p>
<p>The overlap is particularly important—it keeps related ideas connected across
chunk boundaries. I used tiktoken_ruby for accurate token counting because
different models count tokens differently, and that accuracy significantly
affects chunk quality.</p>
<h3 id="challenge-3-implementing-vector-similarity-search">Challenge 3: Implementing vector similarity search</h3>
<p>The <code>ChunkRetriever</code> handles the core vector search functionality. It generates
embeddings for queries and uses sqlite-vec&rsquo;s virtual tables to find the most
similar chunks. The challenge here was tuning the similarity threshold—too low
and you get irrelevant results, too high and you get no results at all.</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"># app/services/rag/chunk_retriever.rb</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Rag</span><span class="o">::</span><span class="no">ChunkRetriever</span>
</span></span><span class="line"><span class="cl">  <span class="no">DEFAULT_THRESHOLD</span> <span class="o">=</span> <span class="mi">1</span><span class="o">.</span><span class="mi">2</span> <span class="c1"># L2 distance, tuned for quality results</span>
</span></span><span class="line"><span class="cl">  <span class="no">DEFAULT_LIMIT</span> <span class="o">=</span> <span class="mi">5</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="ss">query</span><span class="p">:,</span> <span class="ss">threshold</span><span class="p">:</span> <span class="no">DEFAULT_THRESHOLD</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="no">DEFAULT_LIMIT</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">@threshold</span> <span class="o">=</span> <span class="n">threshold</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@limit</span> <span class="o">=</span> <span class="n">limit</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">call</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate embedding for query</span>
</span></span><span class="line"><span class="cl">    <span class="n">query_embedding</span> <span class="o">=</span> <span class="no">EmbeddingGenerator</span><span class="o">.</span><span class="n">generate</span><span class="p">(</span><span class="vi">@query</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 similarity search using sqlite-vec</span>
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span> <span class="o">=</span> <span class="n">execute_vector_search</span><span class="p">(</span><span class="n">query_embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Convert to domain objects with metadata</span>
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">chunk</span><span class="o">|</span> <span class="no">ChunkResult</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">chunk</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="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">execute_vector_search</span><span class="p">(</span><span class="n">embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">sql</span> <span class="o">=</span> <span class="s">&lt;&lt;-SQL
</span></span></span><span class="line"><span class="cl">      <span class="no">SELECT</span> <span class="n">chunks</span><span class="o">.</span><span class="n">*</span><span class="p">,</span> <span class="n">vec_distance_cosine</span><span class="p">(</span><span class="n">chunks</span><span class="o">.</span><span class="n">embedding</span><span class="p">,</span> <span class="sc">?)</span> <span class="n">as</span> <span class="n">distance</span>
</span></span><span class="line"><span class="cl">      <span class="no">FROM</span> <span class="n">vec_chunks</span>
</span></span><span class="line"><span class="cl">      <span class="no">JOIN</span> <span class="n">chunks</span> <span class="no">ON</span> <span class="n">vec_chunks</span><span class="o">.</span><span class="n">rowid</span> <span class="o">=</span> <span class="n">chunks</span><span class="o">.</span><span class="n">id</span>
</span></span><span class="line"><span class="cl">      <span class="no">WHERE</span> <span class="n">vec_distance_cosine</span><span class="p">(</span><span class="n">chunks</span><span class="o">.</span><span class="n">embedding</span><span class="p">,</span> <span class="sc">?)</span> <span class="o">&lt;</span> <span class="p">?</span>
</span></span><span class="line"><span class="cl">      <span class="no">ORDER</span> <span class="no">BY</span> <span class="n">distance</span>
</span></span><span class="line"><span class="cl">      <span class="no">LIMIT</span> <span class="p">?</span>
</span></span><span class="line"><span class="cl">    <span class="no">SQL</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">sanitized_sql</span> <span class="o">=</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="o">.</span><span class="n">sanitize_sql_array</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="n">sql</span><span class="p">,</span> <span class="n">embedding</span><span class="p">,</span> <span class="n">embedding</span><span class="p">,</span> <span class="vi">@threshold</span><span class="p">,</span> <span class="vi">@limit</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="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span><span class="o">.</span><span class="n">connection</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span><span class="n">sanitized_sql</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>
<h3 id="challenge-4-background-job-processing">Challenge 4: Background job processing</h3>
<p>Document processing is computationally expensive—text extraction, chunking, and
embedding generation can take significant time. This work needs to happen
asynchronously to avoid blocking user interactions.</p>
<p>Rails 8&rsquo;s Solid Queue with the Puma integration was an excellent choice for
this use case. No separate worker processes to manage—everything runs in the
background while keeping the app responsive. The in-process approach simplifies
deployment significantly.</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"># app/jobs/process_document_job.rb</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ProcessDocumentJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">retry_on</span> <span class="no">StandardError</span><span class="p">,</span> <span class="ss">wait</span><span class="p">:</span> <span class="ss">:exponentially_longer</span><span class="p">,</span> <span class="ss">attempts</span><span class="p">:</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">perform</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">document</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="ss">:processing</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Extract text content</span>
</span></span><span class="line"><span class="cl">    <span class="n">text_content</span> <span class="o">=</span> <span class="no">DocumentProcessing</span><span class="o">::</span><span class="no">TextExtractor</span><span class="o">.</span><span class="n">extract</span><span class="p">(</span><span class="n">document</span><span class="o">.</span><span class="n">file</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Create chunks with overlap</span>
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span> <span class="o">=</span> <span class="no">DocumentProcessing</span><span class="o">::</span><span class="no">TextChunker</span><span class="o">.</span><span class="n">chunk</span><span class="p">(</span><span class="n">text_content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate embeddings in batches</span>
</span></span><span class="line"><span class="cl">    <span class="n">embeddings</span> <span class="o">=</span> <span class="no">EmbeddingGenerator</span><span class="o">.</span><span class="n">generate_batch</span><span class="p">(</span><span class="n">chunks</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:content</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Store chunks with embeddings</span>
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span><span class="o">.</span><span class="n">each_with_index</span> <span class="k">do</span> <span class="o">|</span><span class="n">chunk</span><span class="p">,</span> <span class="n">index</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">document</span><span class="o">.</span><span class="n">chunks</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">content</span><span class="p">:</span> <span class="n">chunk</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">position</span><span class="p">:</span> <span class="n">chunk</span><span class="o">[</span><span class="ss">:position</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">token_count</span><span class="p">:</span> <span class="n">chunk</span><span class="o">[</span><span class="ss">:token_count</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">embedding</span><span class="p">:</span> <span class="n">embeddings</span><span class="o">[</span><span class="n">index</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="n">document</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="ss">:completed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="o">=&gt;</span> <span class="n">error</span>
</span></span><span class="line"><span class="cl">    <span class="n">document</span><span class="o">.</span><span class="n">update!</span><span class="p">(</span><span class="ss">status</span><span class="p">:</span> <span class="ss">:failed</span><span class="p">,</span> <span class="ss">error_message</span><span class="p">:</span> <span class="n">error</span><span class="o">.</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</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 in-process approach means way less deployment headache while still giving
you reliable background job processing. Sometimes simpler really is better.</p>
<h2 id="frontend-architecture-vuejs-3--composition-api">Frontend architecture: Vue.js 3 + Composition API</h2>
<p>Building a chat interface that doesn&rsquo;t feel clunky is surprisingly hard. You
need to manage conversation state, message history, real-time updates&hellip; it
gets complicated fast.</p>
<p>Vue.js 3&rsquo;s Composition API turned out to be perfect for this. It gives you
these clean patterns for organizing complex component logic without everything
turning into spaghetti code. The chat interface keeps track of the
conversation, shows you messages as they come in, and makes citations
clickable—click one and it&rsquo;ll highlight the exact passage in the document.</p>
<p>I used Pinia for state management because it makes debugging so much easier,
and the whole component structure follows that separation of concerns principle
that keeps you sane when the app gets complex.</p>
<div class="code-block code-line-numbers" 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="c1">// ChatInterface.vue (simplified)
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">ref</span><span class="p">,</span> <span class="nx">computed</span><span class="p">,</span> <span class="nx">onMounted</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;vue&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">useChatStore</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;@/stores/chat&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setup</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">chatStore</span> <span class="o">=</span> <span class="nx">useChatStore</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">message</span> <span class="o">=</span> <span class="nx">ref</span><span class="p">(</span><span class="s1">&#39;&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">loading</span> <span class="o">=</span> <span class="nx">ref</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">sendMessage</span> <span class="o">=</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">message</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">trim</span><span class="p">())</span> <span class="k">return</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="nx">loading</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">      <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kr">await</span> <span class="nx">chatStore</span><span class="p">.</span><span class="nx">sendMessage</span><span class="p">(</span><span class="nx">message</span><span class="p">.</span><span class="nx">value</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nx">message</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">&#39;&#39;</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span> <span class="k">finally</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">loading</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="kc">false</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="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">message</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">loading</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">currentChat</span><span class="o">:</span> <span class="nx">computed</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="nx">chatStore</span><span class="p">.</span><span class="nx">currentChat</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="nx">sendMessage</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></code></pre></div></div>
<h2 id="citation-extraction-and-storage">Citation extraction and storage</h2>
<p>Here&rsquo;s something that drives me crazy about some AI apps: you get these
confident-sounding answers but have no idea where they came from. That&rsquo;s
terrible for user trust.</p>
<p>So I made sure every answer comes with citations. The <code>AnswerGenerator</code> service
basically tells the AI &ldquo;hey, when you answer this, tell me exactly which chunks
you used&rdquo; and stores all that in a nice structured format. This way users can
actually verify the answers, which is huge for building trust.</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"># app/services/rag/answer_generator.rb</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Rag</span><span class="o">::</span><span class="no">AnswerGenerator</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="ss">context</span><span class="p">:,</span> <span class="ss">question</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@context</span> <span class="o">=</span> <span class="n">context</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@question</span> <span class="o">=</span> <span class="n">question</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">call</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">ruby_llm</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">messages</span><span class="p">:</span> <span class="n">build_prompt</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 class="p">,</span> <span class="c1"># Lower temperature for more consistent responses</span>
</span></span><span class="line"><span class="cl">      <span class="ss">response_format</span><span class="p">:</span> <span class="p">{</span> <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;json_object&#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></span><span class="line"><span class="cl">    <span class="n">parse_response</span><span class="p">(</span><span class="n">response</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="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">build_prompt</span>
</span></span><span class="line"><span class="cl">    <span class="no">PromptBuilder</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">context</span><span class="p">:</span> <span class="vi">@context</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">question</span><span class="p">:</span> <span class="vi">@question</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">citation_format</span><span class="p">:</span> <span class="s2">&#34;structured_json&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">build</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">parse_response</span><span class="p">(</span><span class="n">response</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</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">response</span><span class="o">.</span><span class="n">content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="no">AnswerResult</span><span class="o">.</span><span class="n">new</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">data</span><span class="o">[</span><span class="s1">&#39;answer&#39;</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">citations</span><span class="p">:</span> <span class="n">build_citations</span><span class="p">(</span><span class="n">data</span><span class="o">[</span><span class="s1">&#39;citations&#39;</span><span class="o">]</span> <span class="o">||</span> <span class="o">[]</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">metadata</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="n">response</span><span class="o">.</span><span class="n">model</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">usage</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">usage</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="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">build_citations</span><span class="p">(</span><span class="n">citation_data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">citation_data</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">citation</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="ss">chunk_id</span><span class="p">:</span> <span class="n">citation</span><span class="o">[</span><span class="s1">&#39;chunk_id&#39;</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">document_id</span><span class="p">:</span> <span class="n">citation</span><span class="o">[</span><span class="s1">&#39;document_id&#39;</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">document_title</span><span class="p">:</span> <span class="n">citation</span><span class="o">[</span><span class="s1">&#39;document_title&#39;</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">relevance</span><span class="p">:</span> <span class="n">citation</span><span class="o">[</span><span class="s1">&#39;relevance_score&#39;</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">position</span><span class="p">:</span> <span class="n">citation</span><span class="o">[</span><span class="s1">&#39;position_in_document&#39;</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 class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>All those citations get stored as JSON in the messages table, which means you
can replay conversations later and see exactly how the AI arrived at its
answers. Pretty handy for debugging and audit trails.</p>
<h2 id="production-deployment-strategy">Production deployment strategy</h2>
<p>Getting an AI app to production is&hellip; an adventure. You&rsquo;ve got infrastructure,
security, and a million operational concerns to think about. I went with Docker
containers and Fly.io because they strike a nice balance between power and
simplicity.</p>
<h3 id="container-architecture">Container architecture</h3>
<p>The multi-stage Dockerfile was actually pretty fun to build. It optimizes for
both development and production, which means faster builds when you&rsquo;re
iterating and smaller images when you&rsquo;re deploying:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-dockerfile">
        <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-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Multi-stage production Dockerfile</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">ruby:3.3-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">/app</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> Gemfile Gemfile.lock ./<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> bundle install --deployment --without development test<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Build stage for frontend assets</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">node:18-alpine</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">frontend-build</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">/app/frontend</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> frontend/package*.json ./<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> npm ci<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> frontend/ ./<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> npm run build<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Production stage</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">ruby:3.3-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">production</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">/app</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>base /usr/local/bundle/ /usr/local/bundle/<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> . .<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>frontend-build /app/frontend/dist /app/public/frontend<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Production configuration and startup</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> bin/rails assets:precompile<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">EXPOSE</span><span class="w"> </span><span class="s">8080</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;./bin/docker-entrypoint&#34;</span><span class="p">]</span></span></span></code></pre></div></div>
<h3 id="operational-stuff-that-actually-matters">Operational stuff that actually matters</h3>
<p>Production isn&rsquo;t just about getting it running—it&rsquo;s about keeping it running.
Here&rsquo;s what I built in:</p>
<ul>
<li><strong>Health checks</strong>: Custom endpoints so I can actually tell if the app and database are happy</li>
<li><strong>Proper logging</strong>: Structured logging with correlation IDs because debugging production without context is hell</li>
<li><strong>Secrets management</strong>: Rails credentials for API keys (never commit those to git!)</li>
<li><strong>Persistent storage</strong>: Fly.io persistent volumes for the SQLite database and uploaded files</li>
<li><strong>Cross-platform builds</strong>: Docker buildx so I can build on my ARM64 Mac but deploy to AMD64 servers</li>
</ul>
<p>All this stuff matters way more than most people think when they&rsquo;re starting out.</p>
<h2 id="code-quality-and-testing-approach">Code quality and testing approach</h2>
<p>Look, here&rsquo;s the thing about AI systems: they&rsquo;re nondeterministic. The same
input can give you slightly different outputs, which makes testing&hellip;
interesting. But comprehensive testing is still absolutely crucial.</p>
<p>The test suite covers all the important bits:</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"># RAG Pipeline Integration Test</span>
</span></span><span class="line"><span class="cl"><span class="no">RSpec</span><span class="o">.</span><span class="n">describe</span> <span class="s2">&#34;RAG Pipeline Integration&#34;</span><span class="p">,</span> <span class="ss">type</span><span class="p">:</span> <span class="ss">:request</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">it</span> <span class="s2">&#34;processes document and answers question with citations&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Upload document</span>
</span></span><span class="line"><span class="cl">    <span class="n">document</span> <span class="o">=</span> <span class="n">create_document_with_file</span><span class="p">(</span><span class="s2">&#34;sample.pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Process document through pipeline</span>
</span></span><span class="line"><span class="cl">    <span class="no">ProcessDocumentJob</span><span class="o">.</span><span class="n">perform_now</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">expect</span><span class="p">(</span><span class="n">document</span><span class="o">.</span><span class="n">reload</span><span class="o">.</span><span class="n">status</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;completed&#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="n">document</span><span class="o">.</span><span class="n">chunks</span><span class="o">.</span><span class="n">count</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">be</span> <span class="o">&gt;</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Ask question</span>
</span></span><span class="line"><span class="cl">    <span class="n">retriever</span> <span class="o">=</span> <span class="no">Rag</span><span class="o">::</span><span class="no">ChunkRetriever</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">query</span><span class="p">:</span> <span class="s2">&#34;What is the main topic?&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span> <span class="o">=</span> <span class="n">retriever</span><span class="o">.</span><span class="n">call</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate answer</span>
</span></span><span class="line"><span class="cl">    <span class="n">generator</span> <span class="o">=</span> <span class="no">Rag</span><span class="o">::</span><span class="no">AnswerGenerator</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">context</span><span class="p">:</span> <span class="n">chunks</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">question</span><span class="p">:</span> <span class="s2">&#34;What is the main topic?&#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">answer</span> <span class="o">=</span> <span class="n">generator</span><span class="o">.</span><span class="n">call</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">answer</span><span class="o">.</span><span class="n">content</span><span class="p">)</span><span class="o">.</span><span class="n">not_to</span> <span class="n">be_empty</span>
</span></span><span class="line"><span class="cl">    <span class="n">expect</span><span class="p">(</span><span class="n">answer</span><span class="o">.</span><span class="n">citations</span><span class="p">)</span><span class="o">.</span><span class="n">not_to</span> <span class="n">be_empty</span>
</span></span><span class="line"><span class="cl">    <span class="n">expect</span><span class="p">(</span><span class="n">answer</span><span class="o">.</span><span class="n">citations</span><span class="o">.</span><span class="n">first</span><span class="o">[</span><span class="ss">:document_id</span><span class="o">]</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="n">document</span><span class="o">.</span><span class="n">id</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>Right now I&rsquo;ve got 222 passing tests, including:</p>
<ul>
<li>Unit tests for all the services and models</li>
<li>Integration tests that test the whole RAG pipeline end-to-end</li>
<li>API endpoint tests for every controller</li>
<li>Frontend component tests for the Vue.js interfaces</li>
</ul>
<p>That many tests might seem like overkill for a portfolio project, but when
you&rsquo;re dealing with AI systems, you need all the confidence you can get.</p>
<h2 id="key-technical-trade-offs">Key technical trade-offs</h2>
<p>Building Ragtime meant making some interesting calls. Here are the big ones:</p>
<h3 id="sqlite-vs-postgresql-for-vector-storage">SQLite vs PostgreSQL for vector storage</h3>
<ul>
<li><strong>What I chose</strong>: | SQLite + sqlite-vec</li>
<li><strong>Why</strong>: Rails 8&rsquo;s SQLite optimizations make production-scale deployment viable</li>
<li><strong>The trade-off</strong>: Native extension complexity vs single-container deployment simplicity</li>
</ul>
<p>Here&rsquo;s something that might surprise you: Rails 8 ships with SQLite optimizations
that can handle 50K concurrent users and up to 50K writes/sec. That&rsquo;s legitimate
production scale that completely changes the old assumption that SQLite is just
for small apps.</p>
<p>The real challenge wasn&rsquo;t SQLite itself—it was the sqlite-vec extension. That
required significant engineering effort to make work reliably—manual
bootstrapping, custom test support, health check verification. But with Rails 8&rsquo;s
improvements, choosing SQLite for production is actually a defensible decision
for many use cases.</p>
<h3 id="background-jobs-solid-queue-vs-sidekiq">Background jobs: Solid Queue vs Sidekiq</h3>
<ul>
<li><strong>What I chose</strong>: Solid Queue with in-process Puma integration</li>
<li><strong>Why</strong>: Rails 8 integration means no separate worker processes to manage</li>
<li><strong>The trade-off</strong>: Less isolation vs way simpler deployment</li>
</ul>
<p>This leverages Rails 8&rsquo;s new features while cutting down on operational
complexity. If this were a bigger system, I&rsquo;d probably go with dedicated
Sidekiq workers for better isolation and monitoring.</p>
<h3 id="frontend-vuejs-vs-hotwire">Frontend: Vue.js vs Hotwire</h3>
<ul>
<li><strong>What I chose</strong>: Vue.js SPA</li>
<li><strong>Why</strong>: Better UX for chat interfaces with complex state management</li>
<li><strong>The trade-off</strong>: More complex setup vs staying within the Rails ecosystem</li>
</ul>
<p>Vue.js gives you better tools for managing conversation state, real-time
updates, and interactive citations—all critical for a chat experience that
doesn&rsquo;t feel clunky.</p>
<h2 id="what-id-do-differently-at-scale">What I&rsquo;d do differently at scale</h2>
<p>Ragtime is perfectly suited for its purpose as a portfolio project that
demonstrates solid engineering and trade-offs. But if I were building this for
different production scenarios? Some decisions might change:</p>
<ol>
<li><strong>Vector extension</strong>: For really large scale, I&rsquo;d evaluate pgvector for its mature ecosystem, but with Rails 8&rsquo;s SQLite improvements, SQLite remains viable for many production workloads</li>
<li><strong>Background jobs</strong>: Dedicated Sidekiq workers for better isolation and monitoring at larger scale</li>
<li><strong>Asset serving</strong>: CDN integration for static assets</li>
<li><strong>Monitoring</strong>: Full observability stack with Prometheus, Grafana, and proper alerting</li>
<li><strong>Caching</strong>: Redis for frequent queries and expensive operations</li>
<li><strong>Security</strong>: Zero-trust architecture with proper API rate limiting</li>
</ol>
<p>The key is knowing when to optimize for simplicity and when to optimize for
scale—and with Rails 8, that scale threshold is higher than most people think.</p>
<h2 id="lessons-learned">Lessons learned</h2>
<p>Building Ragtime provided valuable insights into modern AI application development:</p>
<p><strong>Technical learnings</strong></p>
<ul>
<li>sqlite-vec is not plug-and-play—it requires significant bootstrapping and error handling</li>
<li>Rails 8 features significantly improve developer experience for API applications</li>
<li>Vector similarity tuning is crucial for RAG quality—threshold selection requires testing and iteration</li>
<li>Container cross-platform builds require careful dependency management</li>
<li>Native extensions in production containers need special handling and verification</li>
</ul>
<p><strong>Process learnings</strong></p>
<ul>
<li>Comprehensive test coverage is essential for AI systems with nondeterministic outputs</li>
<li>Documentation as a design tool prevents over-engineering</li>
<li>Simple deployment strategies accelerate iteration and learning</li>
<li>Error boundaries and graceful degradation are non-negotiable for production AI systems</li>
</ul>
<p><strong>Architecture insights</strong></p>
<ul>
<li>Modularity enables testing and iteration on complex pipelines</li>
<li>Background job patterns determine user experience quality</li>
<li>Separation of concerns simplifies AI integration</li>
<li>Production readiness requires operational thinking from day one</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>Ragtime demonstrates how to build modern AI-powered applications with solid engineering practices. The system showcases:</p>
<ul>
<li><strong>System architecture</strong>: Clean separation of concerns with modern Rails 8 patterns</li>
<li><strong>AI integration</strong>: Practical RAG implementation with production considerations</li>
<li><strong>Frontend development</strong>: Vue.js 3 with proper state management and UX focus</li>
<li><strong>DevOps practices</strong>: Container deployment with operational awareness</li>
<li><strong>Code quality</strong>: Comprehensive testing and maintainable code organization</li>
</ul>
<p>More importantly, it shows how to make thoughtful technology decisions based on
project constraints rather than simply following trends. Sometimes the right
solution isn&rsquo;t the most complex one—it&rsquo;s the one that solves the actual problem
efficiently and maintainably.</p>
<p><strong><a href="https://ragtime-demo.fly.dev" target="_blank" rel="noopener noreffer ">Request access to live demo</a></strong> - Password-protected demo
<strong><a href="https://github.com/grymoire7/ragtime" target="_blank" rel="noopener noreffer ">View source code</a></strong> - Complete implementation
<strong><a href="https://tracyatteberry.com/about" target="_blank" rel="noopener noreffer ">Portfolio &amp; contact</a></strong> - More projects and info</p>
<p>If you need the kind of technical leadership and engineering excellence that
balances technical chops with practical constraints to build solutions that
actually work in the real world, please reach out.</p>
]]></description></item></channel></rss>