<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Cayman Roden</title>
    <description>The latest articles on DEV Community by Cayman Roden (@chunkytortoise_57).</description>
    <link>https://dev.to/chunkytortoise_57</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3761700%2Fda8ff5ef-a77b-4f18-b979-77e62ddc197b.jpg</url>
      <title>DEV Community: Cayman Roden</title>
      <link>https://dev.to/chunkytortoise_57</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chunkytortoise_57"/>
    <language>en</language>
    <item>
      <title>DTI Beats FICO for Prime Borrowers: What SHAP Values Reveal About Credit Risk</title>
      <dc:creator>Cayman Roden</dc:creator>
      <pubDate>Fri, 03 Apr 2026 10:58:13 +0000</pubDate>
      <link>https://dev.to/chunkytortoise_57/dti-beats-fico-for-prime-borrowers-what-shap-values-reveal-about-credit-risk-3i6c</link>
      <guid>https://dev.to/chunkytortoise_57/dti-beats-fico-for-prime-borrowers-what-shap-values-reveal-about-credit-risk-3i6c</guid>
      <description>&lt;p&gt;If you build credit models, you probably treat FICO as your primary signal. You are not wrong, exactly, but you are almost certainly missing the highest-value improvement available to you. For your best borrowers, the ones above 720, FICO is already priced in. The risk that matters in that segment is somewhere else entirely.&lt;/p&gt;

&lt;p&gt;That somewhere else is debt-to-income ratio. And the way to see it is SHAP.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;The analysis runs on 50,000 synthetic consumer installment loans, calibrated to Lending Club historical distributions with a fixed seed (42) and a roughly 15% default rate. That calibration matters: the findings hold up against real-world portfolio shapes, not toy data.&lt;/p&gt;

&lt;p&gt;Three models were compared: Logistic Regression, Random Forest, and Gradient Boosting. The comparison is deliberate. Any single model can produce feature importance numbers that look plausible but are artifacts of that model's structure. Running three models side by side, and then applying SHAP values across all three, lets you distinguish genuine signal from modeling quirks.&lt;/p&gt;

&lt;p&gt;SHAP (SHapley Additive exPlanations) is the right tool here, not standard feature importance. Feature importance tells you which features the model uses most. SHAP tells you how each feature pushes each individual prediction higher or lower, with a sign. You can segment the SHAP values by any borrower characteristic, which is the only way to surface the finding described below.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Finding
&lt;/h2&gt;

&lt;p&gt;Across the full lending book, FICO dominates. It has the highest mean absolute SHAP value. This is expected. FICO is a compressed summary of payment history, credit utilization, length of history, and several other factors. Of course it predicts default.&lt;/p&gt;

&lt;p&gt;But segment the portfolio to FICO 720 and above, and the picture changes. In that segment, DTI ratio becomes the dominant predictor, explaining roughly 38% of default variance. FICO drops.&lt;/p&gt;

&lt;p&gt;The SHAP beeswarm plot makes this concrete. For the full population, FICO values fan out widely on both sides of zero, meaning high and low FICO scores are both doing significant explanatory work. In the 720+ segment, those FICO dots compress toward zero. The DTI dots spread out instead.&lt;/p&gt;

&lt;p&gt;Why does this happen? Prime borrowers have already passed a FICO floor. The lender screened on FICO, so FICO variance in the approved pool is low. When variance in a feature is low, that feature cannot explain much of the outcome variance. What is left? Income and debt load. A borrower with a 740 FICO and a 44% DTI is meaningfully different from a borrower with a 740 FICO and a 22% DTI, but FICO cannot see that distinction. DTI can.&lt;/p&gt;

&lt;p&gt;The practical implication is that lenders who screen only on FICO are systematically underestimating the tail risk sitting above their prime cutoff.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Dollar Math
&lt;/h2&gt;

&lt;p&gt;For a $100M consumer installment portfolio, the numbers are specific enough to put in a business case.&lt;/p&gt;

&lt;p&gt;Tightening DTI thresholds in the 720-760 FICO band from 45% to 38% reduces annual expected losses by an estimated $600K-$900K. The assumptions: 15% portfolio default rate, 40% loss given default, and approximately 38% of defaults in this band being DTI-driven rather than FICO-driven.&lt;/p&gt;

&lt;p&gt;The action does not require changing the FICO cutoff, and it does not decline additional applications. It re-weights the approval decision within the existing prime segment. This is a policy parameter change, not a model change. It can go into effect without a model validation cycle.&lt;/p&gt;

&lt;p&gt;That combination -- six-figure loss reduction with no new model, no approval volume impact, and no model governance overhead -- is rare. Most credit improvement levers require tradeoffs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Regulatory Angle
&lt;/h2&gt;

&lt;p&gt;There is a fair lending dimension worth paying attention to.&lt;/p&gt;

&lt;p&gt;FICO score can act as a proxy for protected class characteristics. This is well documented. ECOA (Reg B) and the Fair Housing Act require that adverse action be based on neutral, income-related factors wherever possible. DTI is precisely that: it measures a borrower's actual debt load relative to income. It is not a protected characteristic, and it does not have the proxy concerns that FICO carries.&lt;/p&gt;

&lt;p&gt;A DTI-first screening approach in the prime band is also more defensible under a fair lending examination. If a regulator asks why you denied a 725 FICO borrower, "DTI of 46% exceeds our prime-band threshold of 38%" is a cleaner answer than any explanation that depends on the FICO composite. Examiners know what goes into FICO.&lt;/p&gt;

&lt;p&gt;This is not a hypothetical regulatory posture. The CFPB has consistently indicated in supervisory guidance that income-based factors are preferred when they are genuinely predictive. The SHAP analysis confirms that DTI is genuinely predictive in this segment.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Reproduce It
&lt;/h2&gt;

&lt;p&gt;The full analysis runs on the Credit Risk Explorer page of the live dashboard at &lt;a href="https://finance-analytics-portfolio.streamlit.app" rel="noopener noreferrer"&gt;finance-analytics-portfolio.streamlit.app&lt;/a&gt;. The source is at &lt;a href="https://github.com/ChunkyTortoise/finance-analytics-portfolio" rel="noopener noreferrer"&gt;github.com/ChunkyTortoise/finance-analytics-portfolio&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To run SHAP on the credit model locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;shap&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;analysis.credit_models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;train_models&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CREDIT_FEATURES&lt;/span&gt;

&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;train_models&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;random_forest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;explainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TreeExplainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;shap_values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;explainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shap_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Segment to prime borrowers only
&lt;/span&gt;&lt;span class="n"&gt;prime_mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fico_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt;
&lt;span class="n"&gt;shap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary_plot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shap_values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;prime_mask&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;prime_mask&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The beeswarm plot that results from the segmented call is where the DTI flip becomes visible.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Every analyst building a credit model should run SHAP on their high-FICO segment separately, not just on the full portfolio. The full-population feature importance is almost always dominated by FICO, which makes it easy to conclude that FICO is the only signal worth tracking. That conclusion is wrong for your prime borrowers.&lt;/p&gt;

&lt;p&gt;DTI is the actionable variable in the segment where it matters most. The improvement is a policy change, not a modeling exercise. And it happens to be the more defensible choice under fair lending scrutiny.&lt;/p&gt;

&lt;p&gt;The finding generalizes: any model trained on a population that has already been filtered on a key feature will systematically underweight the variables that matter within that filtered population. SHAP, segmented by the filter criterion, is the fastest way to find those blind spots.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Cayman Roden is a data analyst specializing in financial services analytics. Full analysis at &lt;a href="https://finance-analytics-portfolio.streamlit.app" rel="noopener noreferrer"&gt;Finance Analytics Portfolio&lt;/a&gt; | &lt;a href="https://github.com/ChunkyTortoise/finance-analytics-portfolio" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why Your AI Portfolio Needs Observability, Not More Repos</title>
      <dc:creator>Cayman Roden</dc:creator>
      <pubDate>Wed, 25 Mar 2026 07:42:00 +0000</pubDate>
      <link>https://dev.to/chunkytortoise_57/why-your-ai-portfolio-needs-observability-not-more-repos-2ac0</link>
      <guid>https://dev.to/chunkytortoise_57/why-your-ai-portfolio-needs-observability-not-more-repos-2ac0</guid>
      <description>&lt;p&gt;I have 15 GitHub repos and 10,800+ automated tests. My freelance rate was stuck at $40-55/hr. After running a multi-model research pipeline (Perplexity, Gemini, Grok, ChatGPT) to figure out what to build next, every model converged on the same answer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop building. Start observing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The production deployment gap is the #1 constraint on AI engineer hiring signal and rates in 2026. Only 11% of enterprises have deployed AI agents to production despite 66% experimenting. The engineers who can prove their systems work at runtime (not just at test time) are 10x rarer than the ones who can build the prototype.&lt;/p&gt;

&lt;p&gt;Here is what I changed and what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gap: 1,109 Tests, Zero Traces
&lt;/h2&gt;

&lt;p&gt;My RAG pipeline (DocExtract) had everything a hiring manager might want to see in a repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agentic RAG with ReAct reasoning loop&lt;/li&gt;
&lt;li&gt;Circuit breaker model fallback (Sonnet to Haiku)&lt;/li&gt;
&lt;li&gt;HITL correction workflow with audit trail&lt;/li&gt;
&lt;li&gt;RAGAS evaluation + LLM-as-judge CI gate&lt;/li&gt;
&lt;li&gt;Kubernetes manifests, Terraform IaC, Docker multi-stage builds&lt;/li&gt;
&lt;li&gt;1,109 tests at 90%+ coverage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it did NOT have: any evidence it had ever processed a real request. No traces. No latency dashboards. No cost-per-request visibility. No runtime quality monitoring.&lt;/p&gt;

&lt;p&gt;A hiring manager clicking the repo would see impressive architecture docs and green CI badges. But they would have no way to verify the system actually works under real conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Sync Sidecar Pattern
&lt;/h2&gt;

&lt;p&gt;The key constraint: observability must never slow down the request path.&lt;/p&gt;

&lt;p&gt;The pattern is simple. FastAPI BackgroundTasks handle all trace submission after the response is sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundTasks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;langfuse_trace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extraction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_extraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;langfuse_flush&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user gets their extraction result immediately. Langfuse receives the full trace (model, tokens, latency, confidence) in the background. This adds ~0ms to the request path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiered Evaluation: The $0 CI Gate
&lt;/h2&gt;

&lt;p&gt;Running LLM-as-a-judge on every PR is financially unviable for a solo engineer. At $0.003+ per metric per test case, a 50-case golden set costs $50-100/month in CI alone.&lt;/p&gt;

&lt;p&gt;The solution is tiered evaluation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 (every PR, $0):&lt;/strong&gt; Deterministic checks only&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schema conformance (Pydantic validation)&lt;/li&gt;
&lt;li&gt;Confidence scores in 0.0-1.0 range&lt;/li&gt;
&lt;li&gt;Field completeness (no empty extractions)&lt;/li&gt;
&lt;li&gt;Citation grounding (extracted values appear in source text)&lt;/li&gt;
&lt;li&gt;Baseline accuracy above 90%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 (nightly cron, API cost):&lt;/strong&gt; LLM-as-a-judge via DeepEval&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Contextual precision&lt;/li&gt;
&lt;li&gt;Faithfulness (hallucination detection)&lt;/li&gt;
&lt;li&gt;Answer relevancy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives fast, reliable feedback on every change while reserving expensive quality checks for nightly validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  PII Sanitization: Non-Negotiable Before Tracing
&lt;/h2&gt;

&lt;p&gt;You cannot send production user data to external tracing services in plain text. Before any trace leaves the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Regex-based PII masking (SSN, credit card, phone, email)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sanitize_for_trace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sanitize_for_trace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero dependencies. Deterministic. Runs before every Langfuse trace submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HITL Data Advantage
&lt;/h2&gt;

&lt;p&gt;Human corrections are not just UX. They are data assets.&lt;/p&gt;

&lt;p&gt;DocExtract's review queue captures structured corrections: original extraction, corrected fields, error type, reviewer ID. This creates organic training data for future fine-tuning without the typical $2K-8K dataset curation cost.&lt;/p&gt;

&lt;p&gt;When correction volume reaches critical mass, this feeds directly into a QLoRA fine-tuning pipeline (DPO pairs already exported in JSONL format).&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add Langfuse from day one.&lt;/strong&gt; Retrofitting observability onto 40+ endpoints is more work than building it in. The Sync Sidecar pattern adds about 10 lines per endpoint.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with cloud-managed everything.&lt;/strong&gt; Self-hosting Langfuse requires ClickHouse + Redis + S3. The cloud free tier (1M spans/month) is the right call for a solo engineer.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tier the eval strategy earlier.&lt;/strong&gt; Running DeepEval on every PR sounded good in theory. The tiered approach (deterministic CI + nightly LLM-judge) is the sustainable pattern.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Real Lesson
&lt;/h2&gt;

&lt;p&gt;The multi-model research consensus was clear: 15 repos with 10K+ tests is more code than most engineers at double my rate. The gap is not engineering skill. It is observable production signal.&lt;/p&gt;

&lt;p&gt;Monitoring and observability represent 70% of production AI work that nobody puts in their portfolio. Adding Langfuse tracing, tiered DeepEval CI gates, PII sanitization, and cost tracking transforms a demo project into a production system.&lt;/p&gt;

&lt;p&gt;The code changes took 2 days. The positioning shift is worth 10x that in hiring signal.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Stack: FastAPI, PostgreSQL + pgvector, Redis, Claude API, Langfuse, DeepEval, ARQ, Docker, Kubernetes, GitHub Actions&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/ChunkyTortoise/docextract" rel="noopener noreferrer"&gt;github.com/ChunkyTortoise/docextract&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiaipythonragcareer</category>
    </item>
    <item>
      <title>Building a Production RAG Pipeline That Actually Survives Monday Morning</title>
      <dc:creator>Cayman Roden</dc:creator>
      <pubDate>Wed, 25 Mar 2026 06:32:50 +0000</pubDate>
      <link>https://dev.to/chunkytortoise_57/building-a-production-rag-pipeline-that-actually-survives-monday-morning-216m</link>
      <guid>https://dev.to/chunkytortoise_57/building-a-production-rag-pipeline-that-actually-survives-monday-morning-216m</guid>
      <description>&lt;p&gt;I spent three months building a document extraction API. The first version worked great in demos. It also silently hallucinated invoice totals, crashed when Claude hit rate limits, and had no way to tell me extraction quality was degrading until a customer filed a support ticket.&lt;/p&gt;

&lt;p&gt;This is the story of three patterns that turned it into something I'd actually deploy: circuit breaker model fallback, a golden eval CI gate, and two-pass extraction with automatic correction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: documents are messy
&lt;/h2&gt;

&lt;p&gt;Every company that processes documents at scale hits the same wall. PDFs arrive in different layouts. Scanned images have OCR artifacts. Emails have attachments nested inside attachments. Template-based extraction tools break the moment a vendor changes their invoice format.&lt;/p&gt;

&lt;p&gt;I needed an API that could accept any document, figure out what it was, and extract the right fields without being pre-configured for each layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: three services, seven steps
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client -&amp;gt; FastAPI REST API -&amp;gt; Redis/ARQ Queue -&amp;gt; Worker Pipeline:
  1. MIME detection + routing
  2. Text extraction (PDF/image/email)
  3. Document classification (Haiku)
  4. Two-pass Claude extraction (Sonnet)
  5. Business rule validation
  6. pgvector HNSW embedding (768-dim)
  7. HMAC-signed webhook delivery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API accepts uploads, deduplicates via SHA-256 hash, and queues a job. The ARQ worker runs the seven-step pipeline asynchronously. Clients get real-time progress via Server-Sent Events.&lt;/p&gt;

&lt;p&gt;Three decisions shaped everything that followed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: Two-pass extraction catches silent failures
&lt;/h2&gt;

&lt;p&gt;The single biggest failure mode in document extraction is &lt;em&gt;silent&lt;/em&gt; bad data. The model returns a plausible-looking JSON response, but the invoice total is wrong or the vendor name is truncated. Nobody notices until downstream accounting breaks.&lt;/p&gt;

&lt;p&gt;Two-pass extraction fixes this. Pass 1 calls Claude Sonnet with a structured JSON prompt and asks for a &lt;code&gt;_confidence&lt;/code&gt; field. If confidence drops below 0.80, Pass 2 fires a second call using Claude's &lt;code&gt;tool_use&lt;/code&gt; API. The model returns corrections as a structured &lt;code&gt;apply_corrections&lt;/code&gt; tool call, which gets merged into the original extraction.&lt;/p&gt;

&lt;p&gt;This catches roughly 15-20% of extractions that would otherwise produce bad data. The remaining 80-85% never pay for a second API call.&lt;/p&gt;

&lt;p&gt;The per-document-type confidence thresholds are configurable: identity documents default to 0.90 (high stakes), receipts to 0.75 (more noise tolerance).&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: Circuit breakers prevent cascading failures
&lt;/h2&gt;

&lt;p&gt;The first time Claude's API hit a rate limit during a batch job, my worker crashed, the queue backed up, and retries made the rate limiting worse. Classic cascading failure.&lt;/p&gt;

&lt;p&gt;The fix was per-model circuit breakers with a fallback chain. Each model (Sonnet, Haiku) gets its own state machine: CLOSED (healthy), OPEN (failing, route to fallback), HALF_OPEN (probe recovery).&lt;/p&gt;

&lt;p&gt;When Sonnet trips after 5 consecutive failures, extraction automatically routes to Haiku. Accuracy drops roughly 14%, but the system stays up. After 60 seconds, the breaker enters HALF_OPEN and probes Sonnet with a single call. If it succeeds, traffic restores.&lt;/p&gt;

&lt;p&gt;The fallback chains are intentionally inverted by role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extraction&lt;/strong&gt;: Sonnet (primary) -&amp;gt; Haiku (fallback). Quality matters most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classification&lt;/strong&gt;: Haiku (primary) -&amp;gt; Sonnet (fallback). Classification is simpler; Haiku-first saves cost without quality loss.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The circuit breaker actually &lt;em&gt;reduces&lt;/em&gt; cost during outages by failing fast instead of burning through retry budgets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: The eval gate makes quality a CI signal
&lt;/h2&gt;

&lt;p&gt;This was the one that changed how I think about AI systems.&lt;/p&gt;

&lt;p&gt;I built a golden eval suite: 24 test fixtures across 6 document types (invoices, receipts, purchase orders, bank statements, medical records, identity documents). Each fixture has ground truth expected output and a recorded model response so the eval runs without API calls.&lt;/p&gt;

&lt;p&gt;The CI gate loads the golden fixtures, scores them against ground truth, and compares to a committed baseline (currently 94.6%). If the score drops more than 2%, the build fails.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Eval Regression Gate -- PASS

| Metric          | Value  |
|-----------------|--------|
| Overall Score   | 0.9462 |
| Baseline        | 0.9462 |
| Tolerance       | +/-0.02|
| Cases           | 24     |
| Brier Score     | 0.0000 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means extraction quality is a first-class CI signal. The same way you wouldn't merge code that drops test coverage below 80%, you can't merge a prompt change that drops extraction accuracy below 92%.&lt;/p&gt;

&lt;p&gt;The eval includes 8 adversarial fixtures designed to break things: corrupted PDFs with null bytes, blank multi-page documents, scanned tables with OCR character substitution (0/O, l/1), duplicate pages, mixed Spanish/English invoices, and redacted bank statements.&lt;/p&gt;

&lt;p&gt;Scoring uses weighted field-level accuracy: critical fields (invoice number, total amount) are weighted 2x. Lists use best-pair alignment. A Brier score measures calibration -- whether 80% confidence actually means 80% accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Extraction accuracy&lt;/td&gt;
&lt;td&gt;94.6% (24 golden fixtures, 6 doc types)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;1,135 passing in ~7 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extraction latency (p50)&lt;/td&gt;
&lt;td&gt;2.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extraction latency (p95)&lt;/td&gt;
&lt;td&gt;6.8s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per extraction (Sonnet)&lt;/td&gt;
&lt;td&gt;~$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per extraction (Haiku)&lt;/td&gt;
&lt;td&gt;~$0.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Circuit breaker recovery&lt;/td&gt;
&lt;td&gt;&amp;lt;60s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I'd still change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Field-level confidence&lt;/strong&gt;: Current confidence is document-level. Field-level scores (total: 0.97, address: 0.61) would let reviewers focus on specific uncertain fields instead of re-reviewing entire documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multilingual prompts&lt;/strong&gt;: Non-English documents extract with degraded accuracy because prompts are English-only. A language-detect layer would extend coverage without model changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The circuit breaker + eval gate combination is the piece I'd carry into any future AI pipeline. Circuit breakers give you availability. The eval gate gives you measurable, CI-enforced quality. Two-pass extraction gives you a way to catch your own mistakes before they reach users.&lt;/p&gt;

&lt;p&gt;None of this is complicated individually. The compound effect of all three is what turns a prototype into something you'd trust with real invoices on a Monday morning.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Stack: FastAPI, ARQ, PostgreSQL + pgvector, Redis, Claude Sonnet/Haiku, Gemini Embeddings, OpenTelemetry, Prometheus, Streamlit&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Code: &lt;a href="https://github.com/ChunkyTortoise/docextract" rel="noopener noreferrer"&gt;github.com/ChunkyTortoise/docextract&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building a Production RAG Pipeline That Actually Works: Lessons from DocExtract</title>
      <dc:creator>Cayman Roden</dc:creator>
      <pubDate>Mon, 23 Mar 2026 04:01:47 +0000</pubDate>
      <link>https://dev.to/chunkytortoise_57/building-a-production-rag-pipeline-that-actually-works-lessons-from-docextract-lmm</link>
      <guid>https://dev.to/chunkytortoise_57/building-a-production-rag-pipeline-that-actually-works-lessons-from-docextract-lmm</guid>
      <description>&lt;h2&gt;
  
  
  The Architecture (and Why It's 3 Services, Not 1)
&lt;/h2&gt;

&lt;p&gt;DocExtract is split into three services: an API, a worker, and a frontend.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User uploads PDF
    → API validates and enqueues job (ARQ/Redis)
    → Worker picks up job asynchronously
        → chunk + embed → pgvector store
        → BM25 index built in memory on retrieval
    → API streams SSE progress to frontend
    → User queries with natural language
        → hybrid retrieval → Claude generates answer with citations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why not one FastAPI service? Because document processing is slow (2-8 seconds per page), and you don't want your API workers blocked. The ARQ queue decouples upload from processing, which lets you scale workers independently and gives you a natural retry boundary.&lt;/p&gt;

&lt;p&gt;The async split also means you can add real-time progress streaming (SSE) to the frontend without any threading complexity - the worker updates job state in Redis, the API polls it, and the frontend gets a 12-step progress bar that actually reflects what's happening.&lt;/p&gt;

&lt;p&gt;The full system has 1,060 tests with 90%+ coverage across the extraction pipeline, retrieval paths, agent evaluator, guardrails, and infrastructure layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Pure Vector Search Fails (and What to Do About It)
&lt;/h2&gt;

&lt;p&gt;Vector search is great at semantic similarity. "mortgage prequalification" and "home loan eligibility" have similar embeddings. But "Section 3.2(b)" - the exact contract clause a user is looking for - doesn't.&lt;/p&gt;

&lt;p&gt;BM25 catches what embeddings miss. Exact product codes, invoice numbers, legal citations, acronyms - these score high on BM25 and often near-zero on cosine similarity.&lt;/p&gt;

&lt;p&gt;The solution is Reciprocal Rank Fusion (RRF). You run both retrievers, rank the results independently, then combine the rank positions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vector_rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;bm25_rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bm25_ranks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record_ids&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bm25_rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;vector_rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constant 60 is the RRF smoothing factor. It prevents very high scores from dominating when one system has zero results. A document ranked #1 by vector and #3 by BM25 scores higher than one ranked #1 by vector alone.&lt;/p&gt;

&lt;p&gt;The API exposes this as a mode parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/v1/records/search?q=Section+3.2&amp;amp;mode=hybrid
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mode=vector&lt;/code&gt; (default), &lt;code&gt;mode=bm25&lt;/code&gt;, or &lt;code&gt;mode=hybrid&lt;/code&gt;. The BM25 index is built in memory at query time from the retrieved vector candidates. No separate BM25 service, no sync complexity. It adds maybe 20ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result&lt;/strong&gt;: 92.6% accuracy on a 16-fixture golden evaluation suite. Pure vector was at 86.9% on the same fixtures.&lt;/p&gt;




&lt;h2&gt;
  
  
  From Static RAG to Agentic RAG
&lt;/h2&gt;

&lt;p&gt;Static RAG forces you to pick one retrieval mode per deployment. The real problem is that retrieval quality varies by query type, and you don't know at deploy time what queries you'll get.&lt;/p&gt;

&lt;p&gt;The solution is a ReAct (Reasoning + Acting) agent that picks the right retrieval approach per-query. Each query goes through Think→Act→Observe cycles, choosing from five tools: &lt;code&gt;search_vectors&lt;/code&gt;, &lt;code&gt;search_bm25&lt;/code&gt;, &lt;code&gt;search_hybrid&lt;/code&gt;, &lt;code&gt;lookup_metadata&lt;/code&gt;, and &lt;code&gt;rerank_results&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The agent is confidence-gated at 0.8 - if it reaches that threshold, it stops iterating. Max 3 iterations caps cost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;iteration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_iterations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;thought&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;think&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;act&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thought&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;observation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;tool_args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;confidence_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="n"&gt;iteration&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: the agent does what a senior engineer does mentally. For "Section 3.2(b)" use BM25 - it's an exact citation. For "documents about loan eligibility" use vector - it's a concept. For ambiguous queries, use hybrid. The difference is the agent makes this decision per-query, not per-deployment.&lt;/p&gt;

&lt;p&gt;At 2-3x the latency of a single retrieval call, this isn't free. But if your users ask both structured queries (exact IDs, clause references) and semantic queries (concepts, summaries), a per-query agent consistently outperforms any static retrieval mode.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Golden Eval CI Gate
&lt;/h2&gt;

&lt;p&gt;This is the piece most RAG pipelines skip, and it's the most important one.&lt;/p&gt;

&lt;p&gt;Without a regression gate, you can accidentally degrade retrieval quality during a refactor and not notice until a user complains. With a gate, a PR that drops accuracy by more than 2% gets blocked automatically.&lt;/p&gt;

&lt;p&gt;The setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;16 document fixtures (contracts, invoices, reports) with expected extraction output in a JSON file&lt;/li&gt;
&lt;li&gt;A pytest test that runs the full pipeline end-to-end against those fixtures&lt;/li&gt;
&lt;li&gt;A pass threshold of 92.6% (the current baseline); anything below 90.6% blocks the merge
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_golden_eval_accuracy&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_eval_suite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixtures&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GOLDEN_FIXTURES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;accuracy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accuracy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;accuracy&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;ACCURACY_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Golden eval failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;accuracy&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &amp;lt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ACCURACY_THRESHOLD&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; passed)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixtures are real documents with personally identifying information removed. The expected outputs cover edge cases: multi-column tables, handwritten fields (which fail gracefully), and mixed-language documents.&lt;/p&gt;

&lt;p&gt;This runs in CI on every PR. It catches prompt regressions, embedding model changes, and chunking strategy changes before they ship.&lt;/p&gt;




&lt;h2&gt;
  
  
  Evaluating the Agent, Not Just the Output
&lt;/h2&gt;

&lt;p&gt;The golden eval gate measures extraction accuracy. For agentic retrieval, you also need to measure whether the agent's behavior was sensible - did it pick the right tools? Did it iterate efficiently?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAGAS pipeline&lt;/strong&gt; with three weighted metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;context_recall&lt;/code&gt; - weight 0.35&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;faithfulness&lt;/code&gt; - weight 0.40&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;answer_relevancy&lt;/code&gt; - weight 0.25&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Faithfulness carries the highest weight because hallucination is the worst failure mode for a document extraction API. A retrieved context that gets misrepresented in the answer is more dangerous than a missed chunk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM-as-judge&lt;/strong&gt; scores outputs against structured rubrics with few-shot examples. It extracts the evidence for each scoring decision - you get an auditable trace, not just a number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent evaluation&lt;/strong&gt; adds three dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool selection quality: Jaccard similarity against expected tool sequences when ground truth is known, redundancy penalty otherwise.&lt;/li&gt;
&lt;li&gt;Iteration efficiency: linear decay from 1.0 at 1 iteration to 0.5 at the max iteration count.&lt;/li&gt;
&lt;li&gt;Confidence calibration: trajectory trend and word-overlap with ground truth across iterations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both RAGAS and agent evaluation are feature-flagged (&lt;code&gt;RAGAS_ENABLED&lt;/code&gt;, &lt;code&gt;LLM_JUDGE_ENABLED&lt;/code&gt;) to avoid CI cost. The golden eval is the mandatory gate. These are the diagnostic layer - run them in staging, not on every PR.&lt;/p&gt;




&lt;h2&gt;
  
  
  Circuit Breakers for LLM Calls
&lt;/h2&gt;

&lt;p&gt;LLM APIs fail. Rate limits, transient 5xx errors, model deprecations - your pipeline will experience all of them. A circuit breaker turns cascading failures into graceful degradation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CircuitState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;CLOSED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;closed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;       &lt;span class="c1"&gt;# Healthy - calls pass through
&lt;/span&gt;    &lt;span class="n"&gt;OPEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;open&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;           &lt;span class="c1"&gt;# Failing - calls rejected immediately
&lt;/span&gt;    &lt;span class="n"&gt;HALF_OPEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;half_open&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="c1"&gt;# Recovering - one probe call allowed
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AsyncCircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;failure_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;recovery_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;half_open_max_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The state machine: after 5 consecutive failures, the circuit opens. All calls fail immediately (no network round-trip). After 60 seconds, one probe call is allowed. If it succeeds, back to CLOSED. If it fails, back to OPEN.&lt;/p&gt;

&lt;p&gt;The non-obvious design choice is &lt;strong&gt;inverting the fallback chain by task type&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extraction&lt;/strong&gt; (quality matters): Sonnet → Haiku fallback. Sonnet is more accurate; fall back to Haiku only under failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classification&lt;/strong&gt; (cost matters): Haiku → Sonnet fallback. Haiku is cheaper and fast enough; escalate to Sonnet only under failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This also means the fallback actually reduces costs in the classification path - a side benefit of designing for failure correctly.&lt;/p&gt;

&lt;p&gt;One more thing: distinguish transient errors from permanent ones. A 429 (rate limit) or 503 (overloaded) should trigger the circuit. A 400 (bad request) is your bug and should never trigger a fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Only trigger circuit on errors that might resolve themselves.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RateLimitError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIStatusError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Observability That Actually Tells You Something
&lt;/h2&gt;

&lt;p&gt;Three layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. OpenTelemetry + Prometheus&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every LLM call emits four metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm_call_duration_ms&lt;/code&gt; - histogram, tagged by model and operation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm_calls_total&lt;/code&gt; - counter, tagged by status (success/failure)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;llm_tokens_total&lt;/code&gt; - counter, split by input/output&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;circuit_breaker_state&lt;/code&gt; - gauge (0=CLOSED, 1=HALF_OPEN, 2=OPEN)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The circuit breaker gauge is the critical one. If it flips to 2 at 2am, you want to know about it before your users do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Grafana Dashboard&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pre-built dashboard with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LLM latency p50/p95/p99 by model (spot the Sonnet vs Haiku difference immediately)&lt;/li&gt;
&lt;li&gt;Calls/sec by status (surface rate limit bursts)&lt;/li&gt;
&lt;li&gt;Circuit breaker state gauge (red when open, green when closed)&lt;/li&gt;
&lt;li&gt;Token consumption rate over time (cost forecasting)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole observability stack runs locally with one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.observability.yml up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That brings up Jaeger (distributed traces), Prometheus (metrics), and Grafana (pre-configured dashboard at localhost:3000).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. LangSmith Tracing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the retrieval path specifically, LangSmith gives you per-query traces: what was retrieved, what the final prompt looked like, what tokens were consumed. When the golden eval catches a regression, LangSmith shows you which document type is failing and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd do differently&lt;/strong&gt;: Ship observability on day one, not as an afterthought. When something breaks in production and you have no metrics, you're debugging blind.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost as a First-Class Metric
&lt;/h2&gt;

&lt;p&gt;Token costs compound fast with agentic workloads. Each ReAct iteration is an LLM call. 3 iterations times 16 parallel workers equals 48 LLM calls per batch. At scale, that adds up in ways that surprise you if you're not tracking it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CostTracker&lt;/code&gt; computes USD cost per request using &lt;code&gt;Decimal&lt;/code&gt; arithmetic against a model pricing table. This matters: float arithmetic accumulates rounding errors across thousands of requests. &lt;code&gt;Decimal&lt;/code&gt; doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model A/B testing&lt;/strong&gt;: &lt;code&gt;ModelABTest&lt;/code&gt; uses SHA-256 hashing of &lt;code&gt;(user_id, experiment_id)&lt;/code&gt; for deterministic variant assignment. The same user always gets the same model - no session contamination from random assignment. Statistical significance is checked via two-sample z-test at n≥30 before drawing conclusions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt versioning&lt;/strong&gt;: prompts are stored as semver files (&lt;code&gt;prompts/{category}/vX.Y.Z.txt&lt;/code&gt;), with the active version env-configurable. &lt;code&gt;PromptRegressionTester&lt;/code&gt; runs the golden eval suite against two prompt versions and flags any regression above 2%. A prompt change that improves accuracy but increases cost gets surfaced as a tradeoff - not automatically accepted.&lt;/p&gt;

&lt;p&gt;One concrete number: switching classification from Sonnet to Haiku (when Sonnet circuit-opens) saves approximately $0.003 per document. At 10,000 documents per month, that's $30/month from one inverted fallback chain. Small per-call, meaningful at volume.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Kubernetes Deploy (And Why It Matters for the Portfolio)
&lt;/h2&gt;

&lt;p&gt;DocExtract now deploys to Kubernetes via 11 Kustomize manifests: namespace, deployments for all three services, services, ingress (nginx, SSE buffering disabled), HPA (API scales 2-8 replicas at 70% CPU, worker scales 2-6), configmap, and secrets template.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Base deploy&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; deploy/k8s/

&lt;span class="c"&gt;# Production overlay (higher replicas, resource limits)&lt;/span&gt;
&lt;span class="nv"&gt;K8S_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production make k8s-apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The production overlay overrides replica counts and resource requests without duplicating manifests. That's the Kustomize pattern.&lt;/p&gt;

&lt;p&gt;The AWS Terraform provisions RDS PostgreSQL 16 and ElastiCache Redis 7 (managed, not containers on EC2). The alembic migrations run automatically on boot via a retry loop in user_data.sh - the worker waits up to 2 minutes for RDS to accept connections before starting.&lt;/p&gt;

&lt;p&gt;GHCR CI publishes three Docker images (api, worker, frontend) tagged with &lt;code&gt;latest&lt;/code&gt; and &lt;code&gt;${{ github.sha }}&lt;/code&gt; on every merge to main.&lt;/p&gt;




&lt;h2&gt;
  
  
  7 Lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hybrid search from the start.&lt;/strong&gt; Adding BM25 to a pure vector system after the fact is straightforward, but designing your retrieval interface to support both modes from day one (via &lt;code&gt;?mode=hybrid&lt;/code&gt;) means you never break existing callers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Golden eval before launch, not after.&lt;/strong&gt; Build your evaluation suite from real documents during development, not post-launch when you're debugging complaints. The cost is low; the signal is high.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Circuit breakers are cheaper than incident response.&lt;/strong&gt; Shipping a circuit breaker takes a day. An LLM API outage that cascades into your whole pipeline taking down a client takes much longer to recover from - and costs trust.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Observability belongs in the infrastructure layer, not the application layer.&lt;/strong&gt; The circuit breaker state is a Prometheus gauge emitted from &lt;code&gt;emit_circuit_breaker_state()&lt;/code&gt;. The LLM call duration is emitted from a &lt;code&gt;trace_llm_call()&lt;/code&gt; context manager. Neither the API routes nor the extraction logic know about metrics - they just call the tracer. That separation means you can add new metrics without touching business logic.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Async workers are the right abstraction for long-running AI tasks.&lt;/strong&gt; Don't block API workers with 8-second document processing. The ARQ queue gives you retries, concurrency control, and a clean separation between "job accepted" and "job complete."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Agentic retrieval outperforms static when query types vary.&lt;/strong&gt; If your users ask structured queries (exact IDs, clause references) and semantic queries (concepts, summaries) in the same system, a per-query retrieval agent consistently outperforms any single retrieval mode - at the cost of 2-3x latency.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Track cost per LLM call from day one.&lt;/strong&gt; Once you add agentic workflows with multiple iterations, cost compounds fast. A &lt;code&gt;CostTracker&lt;/code&gt; built at day 1 costs a few hours; retrofitting it after the fact requires touching every LLM call site.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: github.com/ChunkyTortoise/docextract&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo&lt;/strong&gt;: docextract-frontend.onrender.com&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP server&lt;/strong&gt;: see &lt;code&gt;docs/mcp-integration.md&lt;/code&gt; in the repo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building a RAG pipeline and hit any of these problems, happy to discuss in the comments.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>devops</category>
    </item>
    <item>
      <title>Available for Hire: AI Engineer with 11 Production Repos and 8,500+ Tests</title>
      <dc:creator>Cayman Roden</dc:creator>
      <pubDate>Tue, 10 Feb 2026 05:34:10 +0000</pubDate>
      <link>https://dev.to/chunkytortoise_57/available-for-hire-ai-engineer-with-11-production-repos-and-8500-tests-g5n</link>
      <guid>https://dev.to/chunkytortoise_57/available-for-hire-ai-engineer-with-11-production-repos-and-8500-tests-g5n</guid>
      <description>&lt;h3&gt;
  
  
  About Me
&lt;/h3&gt;

&lt;p&gt;I'm Cayman Roden, an AI engineer who builds production systems — not demos. Over the past year, I've shipped 11 repositories covering RAG pipelines, multi-agent orchestration, BI dashboards, chatbot platforms, and web scraping infrastructure. Every repo has CI, typed code, and comprehensive test coverage (8,500+ tests total, all green).&lt;/p&gt;

&lt;p&gt;My stack: Python, FastAPI, LangChain, Streamlit, PostgreSQL, Redis, Docker.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Build
&lt;/h3&gt;

&lt;p&gt;Here are the numbers from production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;89% LLM cost reduction&lt;/strong&gt; through L1/L2/L3 intelligent caching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;88% cache hit rate&lt;/strong&gt; across retrieval and orchestration layers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&amp;lt;200ms orchestration overhead&lt;/strong&gt; for multi-model AI routing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4.3M tool dispatches/sec&lt;/strong&gt; throughput in agent systems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I specialize in the gap between "it works in a notebook" and "it runs in production." That means proper error handling, caching, monitoring, rate limiting, and deployment infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Services &amp;amp; Pricing
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Fiverr Gigs&lt;/strong&gt; (project-based):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Price Range&lt;/th&gt;
&lt;th&gt;Turnaround&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CSV/Excel to Interactive Dashboard&lt;/td&gt;
&lt;td&gt;$50-$200&lt;/td&gt;
&lt;td&gt;2-5 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG Document Q&amp;amp;A System&lt;/td&gt;
&lt;td&gt;$100-$500&lt;/td&gt;
&lt;td&gt;3-7 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI Chatbot (Lead Qual, Support, Internal)&lt;/td&gt;
&lt;td&gt;$200-$500&lt;/td&gt;
&lt;td&gt;5-10 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Gumroad Products&lt;/strong&gt; (self-serve):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;What You Get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DocQA Engine&lt;/td&gt;
&lt;td&gt;$49&lt;/td&gt;
&lt;td&gt;Production RAG with hybrid search and caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AgentForge&lt;/td&gt;
&lt;td&gt;$39&lt;/td&gt;
&lt;td&gt;Multi-agent orchestration framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scrape-and-Serve&lt;/td&gt;
&lt;td&gt;$29&lt;/td&gt;
&lt;td&gt;Web scraping pipeline with API output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insight Engine&lt;/td&gt;
&lt;td&gt;$39&lt;/td&gt;
&lt;td&gt;BI toolkit for CSV/Excel data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  How to Work With Me
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Browse my portfolio&lt;/strong&gt;: &lt;a href="https://chunkytortoise.github.io" rel="noopener noreferrer"&gt;chunkytortoise.github.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;See the code&lt;/strong&gt;: &lt;a href="https://github.com/ChunkyTortoise" rel="noopener noreferrer"&gt;github.com/ChunkyTortoise&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hire me on Fiverr&lt;/strong&gt;: &lt;a href="https://www.fiverr.com/caymanroden" rel="noopener noreferrer"&gt;fiverr.com/caymanroden&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buy a tool on Gumroad&lt;/strong&gt;: &lt;a href="https://caymanroden.gumroad.com" rel="noopener noreferrer"&gt;caymanroden.gumroad.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect on LinkedIn&lt;/strong&gt;: &lt;a href="https://www.linkedin.com/in/caymanroden" rel="noopener noreferrer"&gt;linkedin.com/in/caymanroden&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm US-based (Pacific time), respond within a few hours, and can start most projects within 24-48 hours.&lt;/p&gt;

&lt;p&gt;If you have a Python or AI project that needs to actually work in production, let's talk.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
