<?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: Vinicius Pereira</title>
    <description>The latest articles on DEV Community by Vinicius Pereira (@vinimabreu).</description>
    <link>https://dev.to/vinimabreu</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4010065%2Ff3d37966-fcdb-4c21-9df3-f47b258b99bd.jpeg</url>
      <title>DEV Community: Vinicius Pereira</title>
      <link>https://dev.to/vinimabreu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vinimabreu"/>
    <language>en</language>
    <item>
      <title>Your LLM's confidence score is lying to you</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:31:33 +0000</pubDate>
      <link>https://dev.to/vinimabreu/your-llms-confidence-score-is-lying-to-you-45le</link>
      <guid>https://dev.to/vinimabreu/your-llms-confidence-score-is-lying-to-you-45le</guid>
      <description>&lt;p&gt;The most expensive bug in an LLM system is not the output that is obviously broken. That one you catch. It is the fluent, well formed, confidently wrong answer that reads exactly like a correct one and walks straight into your system of record because nothing was standing between the model and production.&lt;/p&gt;

&lt;p&gt;"Reads correct" and "is correct" are two different properties, and the model only optimizes the first. So the answer that is almost right, the near miss, comes back just as polished as the answer that is exactly right. You cannot tell them apart by looking. Neither can the model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbg6miuk4yjbad27fqrbx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbg6miuk4yjbad27fqrbx.gif" alt="One LLM output at a time enters the gate, three external signals score it, and the result is routed to auto-accept, human review, or abstain." width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Similarity is not confidence
&lt;/h2&gt;

&lt;p&gt;When teams want to gate on that, they reach for a number. Usually one of two: the model's own reported confidence, or the similarity score of whatever chunk it retrieved. Both feel like confidence. Neither one is.&lt;/p&gt;

&lt;p&gt;Ask a model how sure it is and you get more generated text, sampled from the same distribution that just produced the wrong answer. It is a guess about a guess. And a similarity score measures proximity, not truth. The near miss scores high precisely because it sits close to the right answer. That is the whole trap: a similarity cutoff hands its highest marks to the single most dangerous output in the system, the confident near miss, and waves it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compute it from what you can check
&lt;/h2&gt;

&lt;p&gt;The way out is to stop reading confidence off the model and start computing it from signals you can verify without trusting the model at all. Three of them carry most of the weight.&lt;/p&gt;

&lt;p&gt;Grounding asks whether the output is actually entailed by the evidence you gave it, not whether it reads well. An answer nobody can trace back to a source is not trustworthy, however clean it looks. A lexical-overlap baseline gets you moving, but the real seam is a &lt;code&gt;(claim, evidence) -&amp;gt; float&lt;/code&gt; function, so you can swap in an NLI model or a cross-encoder the moment overlap is too blunt. Entailment over proximity.&lt;/p&gt;

&lt;p&gt;Agreement resamples the generator on the same input and measures whether the answer survives. A model that flaps between three answers when you ask three times is telling you something a single clean draw hides. A minority draw is low confidence even when it parses perfectly. This is the one signal that costs you extra generations, so you price it in on purpose.&lt;/p&gt;

&lt;p&gt;Validation is the cheap one, and the one people skip. Does the output satisfy the schema you declared, every required field present and the right type? A structurally invalid output should never reach the auto-accept ceiling no matter how good the other signals look, so one missing field drags the score down instead of passing silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate, as code
&lt;/h2&gt;

&lt;p&gt;I wrote this up as a small reference implementation, &lt;a href="https://github.com/vinimabreu/confidence-gate" rel="noopener noreferrer"&gt;confidence-gate&lt;/a&gt;, so the pattern has something runnable behind it. The generator goes in as a plain callable, which keeps the whole thing deterministic and runs it with no API key. Wiring it looks like this:&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;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;confidence_gate&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;AgreementSignal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GroundingSignal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ReviewQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ValidationSignal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;invoice_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Swap in a real model call. The gate does not care where the text
&lt;/span&gt;    &lt;span class="c1"&gt;# comes from, only whether it survives the signals.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INV-4021&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Northwind Supplies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1840.00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-02-11&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;


&lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ReviewQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:memory:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ValidationSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;GroundingSignal&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;AgreementSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inv-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract invoice fields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invoice INV-4021 from Northwind Supplies. Date 2026-02-11. Total 1840.00 USD.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aggregate_confidence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the bundled demo over three documents, one clean, one ambiguous, one with no supporting evidence, and it prints the decision for each. Captured output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TASK            DECISION        CONF  SIGNALS
----------------------------------------------------------------
northwind-clean AUTO_ACCEPT     1.00  validation=1.00 grounding=1.00 agreement=1.00
globex-partial  HUMAN_REVIEW    0.50  validation=0.75 grounding=0.50 agreement=0.67
unknown-source  ABSTAIN         0.00  validation=1.00 grounding=0.00 agreement=1.00

review queue: 1 pending
  #1 globex-partial  conf=0.50  :: min aggregate 0.500 (accept&amp;gt;=0.75, abstain&amp;lt;=0.35) -&amp;gt; human_review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middle row is the whole argument. &lt;code&gt;globex-partial&lt;/code&gt; validates against the schema except for one missing field, only half of it is supported by the evidence, and the generator does not fully agree with itself on resampling. No single number screams "wrong", so the gate refuses to auto-accept and refuses to abstain. It routes the item to a person and keeps the receipt.&lt;/p&gt;

&lt;p&gt;The bottom row is the one that would have burned you. &lt;code&gt;unknown-source&lt;/code&gt; is structurally perfect, validation 1.00, and the model reproduces it on every resample, agreement 1.00. By any signal you could read off the model, it is maximally confident. Grounding is 0.00 because nothing in the evidence supports it. A confident, self-consistent answer with no external support, and the gate abstains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tune on the cost of a false accept
&lt;/h2&gt;

&lt;p&gt;Look at how the aggregate falls out. It is the minimum of the signals, not the average. That is deliberate, and it comes from the cost structure rather than a taste for being strict. A cache miss or a re-run costs seconds. A false accept ships a confident wrong answer into the record with no model left in the loop to hedge it. Those two outcomes are not symmetric, so you do not tune the threshold to hit some acceptance rate. You tune it on what a single false accept costs you. A gate is only as strong as its weakest verified signal, so one failing signal is enough to deny auto-accept. Abstaining is cheap. Being confidently wrong is the bill you do not see coming.&lt;/p&gt;

&lt;p&gt;That leaves three outcomes instead of two. Auto-accept when the external signals agree. Human review, with the full audit trail, when they are mixed and a person should look. Abstain when there is no support at all, because refusing to answer beats guessing when you cannot check the guess. The model proposes, the code disposes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;It does not make a wrong answer right. It refuses to ship one silently, which is a smaller and far more achievable goal. If your evidence is itself wrong, grounding will happily confirm a wrong answer against it, so the quality of your source text just became part of your trust boundary. The default entailment is lexical overlap, which is blunt: it catches "the answer cites nothing in the source" but not "the answer inverts the one number that mattered", so anything real gets a stronger function through that seam. Agreement costs you K generations per item, which is real latency and real money traded for a convergence signal. And none of this is a product with uptime promises. It is the shape of the design, made runnable.&lt;/p&gt;

&lt;p&gt;A confident wrong answer and a confident right one are the same string until something outside the model checks. Be that something.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>python</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Clear the Lineup: the passage my RAG retrieved and then threw away in silence</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 14 Jul 2026 23:24:30 +0000</pubDate>
      <link>https://dev.to/vinimabreu/clear-the-lineup-the-passage-my-rag-retrieved-and-then-threw-away-in-silence-1adb</link>
      <guid>https://dev.to/vinimabreu/clear-the-lineup-the-passage-my-rag-retrieved-and-then-threw-away-in-silence-1adb</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rag-quality&lt;/code&gt; is a small, self-contained RAG pipeline: it retrieves passages from a corpus (sparse, dense, and hybrid modes), then generates a grounded answer that cites the passages it used and abstains when the context does not support one. It ships with an eval harness that scores retrieval quality.&lt;/p&gt;

&lt;p&gt;Recently I commented on a dev.to thread about non-deterministic retrieval, the ways a RAG can fetch the right passage and still answer wrong. In reply, the author added one more check to his list: verify the context you asked for is the context you actually got. That check named the exact hole I had in this repo, one stage past retrieval, in assembly, against a bug I had shipped and not noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;The generation step assembles retrieved passages into a context block and sends it to the model. Here is what it did:&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;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&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="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Hit&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&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;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAX_CONTEXT_CHARS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&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;Context passages:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Question: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Answer:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;MAX_CONTEXT_CHARS&lt;/code&gt; is 6000. It joins every retrieved passage, then hard-slices the string to 6000 characters. If the joined context runs longer, the tail is gone. The tail is the lowest-ranked hits, and the lowest-ranked hit is often the one that actually holds the answer: retrieval surfaced it, ranking put it last, the slice deleted it. No exception, no log, no return value that hints anything was cut. The caller gets a shorter prompt and believes it is complete.&lt;/p&gt;

&lt;p&gt;What makes this one sting is the asymmetry a few lines down in the same file:&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;if&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stop_reason&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[warning: answer truncated at the token limit]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The code already knows truncation is dangerous. It shouts when the model's &lt;strong&gt;output&lt;/strong&gt; is cut. It stays silent when the model's &lt;strong&gt;input&lt;/strong&gt; is cut, which is the more dangerous of the two, because a truncated input can quietly remove the one passage the answer depended on and the model will confidently answer from what is left.&lt;/p&gt;

&lt;p&gt;And the tests stayed green because nothing exercised the failure. The sample corpus is 17 short documents; its joined top-k context fits comfortably under 6000 characters, so the slice never removed anything in the bundled runs. The eval harness measures retrieval quality (hit@k, MRR, recall@k), whether the right passage was &lt;strong&gt;retrieved&lt;/strong&gt;, not whether it survived &lt;strong&gt;assembly&lt;/strong&gt; into the prompt. The bug lives in the gap between those two stages, which is precisely the gap no metric was watching.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The whole change, the fix, the tests, and the optional Sentry and Gemini tooling, is one pull request:&lt;/p&gt;


&lt;div class="ltag_github-liquid-tag"&gt;
  &lt;h1&gt;
    &lt;a href="https://github.com/vinimabreu/rag-quality/pull/1" rel="noopener noreferrer"&gt;
      &lt;img class="github-logo" alt="GitHub logo" src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg"&gt;
      &lt;span class="issue-title"&gt;
        Fix silent context truncation in build_prompt
      &lt;/span&gt;
      &lt;span class="issue-number"&gt;#1&lt;/span&gt;
    &lt;/a&gt;
  &lt;/h1&gt;
  &lt;div class="github-thread"&gt;
    &lt;div class="timeline-comment-header"&gt;
      &lt;a href="https://github.com/vinimabreu" rel="noopener noreferrer"&gt;
        &lt;img class="github-liquid-tag-img" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F262471465%3Fv%3D4" alt="vinimabreu avatar"&gt;
      &lt;/a&gt;
      &lt;div class="timeline-comment-header-text"&gt;
        &lt;strong&gt;
          &lt;a href="https://github.com/vinimabreu" rel="noopener noreferrer"&gt;vinimabreu&lt;/a&gt;
        &lt;/strong&gt; posted on &lt;a href="https://github.com/vinimabreu/rag-quality/pull/1" rel="noopener noreferrer"&gt;&lt;time&gt;Jul 14, 2026&lt;/time&gt;&lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class="ltag-github-body"&gt;
      &lt;p&gt;&lt;code&gt;build_prompt&lt;/code&gt; joined every retrieved passage and hard-sliced the result to &lt;code&gt;MAX_CONTEXT_CHARS&lt;/code&gt;, dropping the tail with no exception, log, or return signal. The tail is the lowest-ranked hits, and the lowest-ranked hit can be the one that holds the answer. The same file already warns on output truncation but stayed silent on the input side.&lt;/p&gt;
&lt;p&gt;This packs whole passages in rank order up to the budget, never cuts a passage mid-text, and returns &lt;code&gt;dropped_ids&lt;/code&gt; so the caller announces what was left out, mirroring the existing output-truncation warning.&lt;/p&gt;
&lt;p&gt;Also adds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;env-gated Sentry spans across the pipeline (no-op without &lt;code&gt;sentry-sdk&lt;/code&gt; and &lt;code&gt;SENTRY_DSN&lt;/code&gt;), so an over-budget request shows &lt;code&gt;total_context_chars&lt;/code&gt;, &lt;code&gt;dropped_ids&lt;/code&gt;, and a &lt;code&gt;context.truncated_chars&lt;/code&gt; measurement on the &lt;code&gt;rag.build_prompt&lt;/code&gt; span&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/root_cause.py&lt;/code&gt;, an optional Gemini helper that localizes the loss from a trace plus the source&lt;/li&gt;
&lt;li&gt;five tests that run key-free and offline&lt;/li&gt;
&lt;/ul&gt;

    &lt;/div&gt;
    &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinimabreu/rag-quality/pull/1" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;Stop slicing a string. Pack whole passages in rank order until the next one would blow the budget, never cut one mid-text, and return the ids you dropped so the caller can announce them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-def build_prompt(question: str, hits: list[Hit]) -&amp;gt; str:
-    blocks = [f"[{h.chunk.id}] {h.chunk.text}" for h in hits]
-    context = "\n\n".join(blocks)[: config.MAX_CONTEXT_CHARS]
-    return f"Context passages:\n{context}\n\nQuestion: {question}\n\nAnswer:"
&lt;/span&gt;&lt;span class="gi"&gt;+def build_prompt(question: str, hits: list[Hit]) -&amp;gt; tuple[str, list[str]]:
+    separator = "\n\n"
+    budget = config.MAX_CONTEXT_CHARS
+    blocks = [f"[{hit.chunk.id}] {hit.chunk.text}" for hit in hits]
+
+    kept, dropped_ids, used = [], [], 0
+    for hit, block in zip(hits, blocks):
+        extra = len(block) + (len(separator) if kept else 0)
+        if used + extra &amp;lt;= budget:
+            kept.append(block)
+            used += extra
+        else:
+            dropped_ids.append(hit.chunk.id)
+
+    if dropped_ids:
+        log.warning("context truncated: %d passage(s) dropped: %s",
+                    len(dropped_ids), ", ".join(dropped_ids))
+
+    context = separator.join(kept)
+    prompt = f"Context passages:\n{context}\n\nQuestion: {question}\n\nAnswer:"
+    return prompt, dropped_ids
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That diff is the essence of the change. The shipped version also opens the &lt;code&gt;rag.build_prompt&lt;/code&gt; span from the Sentry section around this same code, which is how those numbers reach the trace.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Generator.answer&lt;/code&gt; now unpacks the tuple and appends a note that mirrors the existing output warning, so the drop is visible in the answer itself:&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;if&lt;/span&gt; &lt;span class="n"&gt;dropped_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dropped_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;[warning: &lt;/span&gt;&lt;span class="si"&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;dropped_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; passage(s) dropped from context: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;note&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The budget stays in characters here on purpose. The stronger version counts the real token budget of the target model; I noted that in the docstring rather than building it, because the point of this fix is the missing signal, not a better ruler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof.&lt;/strong&gt; Same input in both runs: four bulky decoy passages plus a short answer chunk that is retrieved but ranked last, so the joined context is 7343 characters against a 6000 budget. Real console output, captured with the repo's own interpreter, no API key.&lt;/p&gt;

&lt;p&gt;Before, the answer chunk is gone and nothing says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAX_CONTEXT_CHARS      = 6000
full joined context len= 7343
answer chunk id        = expenses::answer
answer text present in FULL joined context? True
answer text present in BUILT prompt?        False
answer sentence in built prompt?            False
------------------------------------------------------------
RESULT: answer chunk SILENTLY DROPPED. No exception, no warning, no log. Caller gets a prompt missing the answer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After, packing skips the one passage that will not fit, keeps the small answer chunk behind it, and reports the id it dropped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;context truncated: 1 passage(s) dropped to fit 6000-char budget: expenses::3
MAX_CONTEXT_CHARS      = 6000
full joined context len= 7343
answer chunk id        = expenses::answer
dropped_ids reported   = ['expenses::3']
answer text present in BUILT prompt?        True
answer sentence in built prompt?            True
------------------------------------------------------------
  expenses::0        -&amp;gt; KEPT (verbatim)
  expenses::1        -&amp;gt; KEPT (verbatim)
  expenses::2        -&amp;gt; KEPT (verbatim)
  expenses::3        -&amp;gt; dropped whole
  expenses::answer   -&amp;gt; KEPT (verbatim)
------------------------------------------------------------
RESULT: answer PACKED IN, and the passage that did not fit (['expenses::3']) is reported to the caller. No silent loss.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four behavioural tests lock the contract: an over-budget answer chunk is either packed in or named in &lt;code&gt;dropped_ids&lt;/code&gt; and never silently gone, over-budget context reports its dropped ids, no passage is ever cut mid-text, and within-budget context drops nothing. A fifth pins the tuple return type so a caller cannot regress to the old string signature. All five run with the repo's own interpreter, no key and no network.&lt;/p&gt;

&lt;p&gt;The rule I kept: any place that decides what the model sees has to announce what it dropped. A retriever that finds the right passage and an assembler that quietly deletes it produce the same wrong answer, but only one of them leaves a trace. The output truncation in this file already knew that. The input side just needed to learn the same manners.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;The fix for "silent" is instrumentation. I added env-gated Sentry spans across the pipeline (off unless &lt;code&gt;SENTRY_DSN&lt;/code&gt; is set and &lt;code&gt;sentry-sdk&lt;/code&gt; is installed, so the repo never hard-depends on it). The span that matters here is &lt;code&gt;rag.build_prompt&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;total_context_chars&lt;/code&gt; and &lt;code&gt;max_context_chars&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;n_dropped&lt;/code&gt; and &lt;code&gt;dropped_ids&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;context.truncated_chars&lt;/code&gt; measurement&lt;/li&gt;
&lt;li&gt;a warning-level event whenever &lt;code&gt;n_dropped &amp;gt; 0&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now a wrong answer on a long-context request is not a mystery. The pipeline is instrumented end to end: rag.answer to rag.retrieve (hit_ids) to rag.build_prompt (dropped_ids) to rag.generate (stop_reason). The trace below exercises the assembly step directly, so it shows rag.answer to rag.build_prompt, where the truncation happens and the dropped ids surface.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqj4o8fse7po54jykttz7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqj4o8fse7po54jykttz7.png" alt=" " width="800" height="812"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwrv3ekwn7kto1ejwp23.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwrv3ekwn7kto1ejwp23.png" alt=" " width="800" height="816"&gt;&lt;/a&gt; On the over-budget request, the &lt;code&gt;rag.build_prompt&lt;/code&gt; span reads &lt;code&gt;total_context_chars&lt;/code&gt; 7343 against a &lt;code&gt;max_context_chars&lt;/code&gt; of 6000, a &lt;code&gt;context.truncated_chars&lt;/code&gt; measurement of 1343, and the &lt;code&gt;dropped_ids&lt;/code&gt;, sitting directly under the rag.answer transaction. The truncation that used to be invisible is now a value on a span.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Use of Google AI
&lt;/h2&gt;

&lt;p&gt;To make the localization repeatable I added &lt;code&gt;scripts/root_cause.py&lt;/code&gt;: it takes a Sentry trace plus the source files that ran, and asks Gemini to point at the line. The prompt is fixed and paste-ready:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are debugging a RAG pipeline that returned a wrong answer. Here is a Sentry trace and the relevant source. The retrieval span shows the correct passage was retrieved, but the final answer is wrong. Localize the exact line where the retrieved context is lost, explain the mechanism, and note whether the loss is signalled to the caller. Then propose a minimal fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The script is env-gated on &lt;code&gt;GOOGLE_API_KEY&lt;/code&gt; or &lt;code&gt;GEMINI_API_KEY&lt;/code&gt; and fails with a clear message when no key is set, so it never runs blind.&lt;/p&gt;

&lt;p&gt;Run against the pre-fix source with the real trace, on &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, it returned this (verbatim):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Sentry trace clearly indicates that the &lt;code&gt;rag.retrieve&lt;/code&gt; span successfully identified &lt;code&gt;expenses::answer&lt;/code&gt; as a relevant passage. However, the subsequent &lt;code&gt;rag.build_prompt&lt;/code&gt; span shows a &lt;code&gt;total_context_chars&lt;/code&gt; of 7343 and a &lt;code&gt;max_context_chars&lt;/code&gt; of 6000, leading to &lt;code&gt;1343&lt;/code&gt; characters being truncated. The symptom confirms that &lt;code&gt;expenses::answer&lt;/code&gt; was "absent from the assembled prompt," which directly led to the LLM's abstention (&lt;code&gt;I could not find this in the provided documents.&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Exact Line Where Context is Lost.&lt;/strong&gt; The context is lost in &lt;code&gt;app/generate.py&lt;/code&gt;, specifically at this line within the &lt;code&gt;build_prompt&lt;/code&gt; function: &lt;code&gt;context = "\n\n".join(blocks)[: config.MAX_CONTEXT_CHARS]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Mechanism of Loss: naive string slicing after concatenation.&lt;/strong&gt; The function joins all blocks into a single long string, then applies a hard character limit by slicing that entire concatenated string. If the total length exceeds &lt;code&gt;config.MAX_CONTEXT_CHARS&lt;/code&gt;, the slice cuts characters from the end of the combined string. Passages later in the &lt;code&gt;hits&lt;/code&gt; list are more likely to be truncated, and even a highly relevant passage like &lt;code&gt;expenses::answer&lt;/code&gt; can be cut off if it falls past the character limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Signalling of Loss.&lt;/strong&gt; "No exception, warning, or dropped-id was reported to the caller." The function simply returns the truncated string; there are no checks, &lt;code&gt;logging.warning&lt;/code&gt; calls, or exceptions that would inform the calling &lt;code&gt;Generator.answer&lt;/code&gt; method. Sentry captured the &lt;code&gt;context.truncated_chars&lt;/code&gt; measurement, but that is instrumentation-level reporting, not a signal within the application's business logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Minimal Fix.&lt;/strong&gt; Adjust &lt;code&gt;build_prompt&lt;/code&gt; to iteratively add passages, respecting &lt;code&gt;config.MAX_CONTEXT_CHARS&lt;/code&gt; without splitting individual passages, so higher-ranked complete passages are prioritized. [It then produced a pack-to-fit loop that stops adding blocks once the budget is reached.]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model landed on the same line I did, read the mechanism straight off the trace measurements, and proposed the same pack-to-fit shape as the fix above. Sentry said which chunk vanished and by how much; Gemini said which line threw it away and why nothing noticed.&lt;/p&gt;

&lt;p&gt;Vinicius Pereira&lt;br&gt;
vinimabreu.dev · github.com/vinimabreu&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>sentry</category>
      <category>googleaichallenge</category>
    </item>
    <item>
      <title>The night I leaked my own payment key, and what caught it</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 14 Jul 2026 21:30:52 +0000</pubDate>
      <link>https://dev.to/vinimabreu/the-night-i-leaked-my-own-payment-key-and-the-circuit-breaker-that-caught-it-1o2c</link>
      <guid>https://dev.to/vinimabreu/the-night-i-leaked-my-own-payment-key-and-the-circuit-breaker-that-caught-it-1o2c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I build a SaaS for small businesses. Restaurants, small shops, the kind of owner who needs to track sales, stock, cash flow and finances without wrestling a heavyweight ERP. For the first months I was heads-down on features, because that is what pays: the product has to solve a real problem the day you ship it.&lt;/p&gt;

&lt;p&gt;Then one night taught me something no feature ever could.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake was entirely mine
&lt;/h2&gt;

&lt;p&gt;During a deploy, a configuration of my own doing exposed a payment API key in production. The exposure window was short, but the risk was not theoretical. Anyone who found that credential could try to use it. In production. Against real money.&lt;/p&gt;

&lt;p&gt;Here is the part I got right, and it was as much luck as foresight: weeks earlier I had built an emergency mechanism for exactly the kind of unlikely event I did not believe would ever happen to me.&lt;/p&gt;

&lt;p&gt;Every payment integration ran behind a monitor that watched for abnormal usage. A sudden jump in transaction volume. Calls from origins it did not recognize. Sharp changes in behavior. Anything outside the profile it expected. If any of those tripped, the integration did not wait for a human. It moved itself into a protection mode, blocked new operations, and paged me immediately.&lt;/p&gt;

&lt;p&gt;That is exactly what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The circuit breaker did its job
&lt;/h2&gt;

&lt;p&gt;The monitor flagged the anomaly and locked the integration into protection mode before a single improper transaction went through. The alert woke me in the middle of the night. I revoked the compromised credential, generated a new key, rolled it across the whole infrastructure, and validated everything before the first customer opened the app in the morning.&lt;/p&gt;

&lt;p&gt;No customer was affected. No improper transaction went through. No financial data was exposed.&lt;/p&gt;

&lt;p&gt;And none of that made the mistake any smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;The lesson was not "be more careful." Careful is not a strategy. I had been careful, and I still shipped the mistake at the worst possible layer.&lt;/p&gt;

&lt;p&gt;The real lesson was that security cannot rest on not making mistakes. It has to assume mistakes will happen, and it has to cap the blast radius when they do. The thing that saved me that night was not my discipline. It was a layer I had built specifically to survive my own discipline failing.&lt;/p&gt;

&lt;p&gt;So I rebuilt the security architecture around that idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automated rotation of sensitive credentials, so a leaked key has a short life by default.&lt;/li&gt;
&lt;li&gt;Hard segregation between development and production, so the two can never bleed into each other.&lt;/li&gt;
&lt;li&gt;Detailed audit logging of critical events, so every sensitive action leaves a trail.&lt;/li&gt;
&lt;li&gt;Extra validation during deploy to stop secrets from being exposed in the first place.&lt;/li&gt;
&lt;li&gt;Continuous monitoring of every external integration.&lt;/li&gt;
&lt;li&gt;Real-time alerts for anything that looks abnormal.&lt;/li&gt;
&lt;li&gt;A mandatory security review before any release reaches production.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The question I ask before I ship anything now
&lt;/h2&gt;

&lt;p&gt;Every new feature I build now starts from one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If I make a mistake tomorrow at 3 a.m., will the system protect my customers before they notice?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That night changed what engineering means to me. Building is no longer just shipping features. It is building systems that expect human failure and protect the people who trust them anyway.&lt;/p&gt;

&lt;p&gt;The bug that night was mine. The thing that smashed it was a layer I had built for a version of me that would eventually slip. Build that layer before you need it. You will need it.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My weekend challenge entry: an AI announcer for the beach-sport duels my futevôlei crew plays every day. Gemini writes the call, ElevenLabs voices it. Ring the bell yourself, the demo is live.</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Fri, 10 Jul 2026 12:10:16 +0000</pubDate>
      <link>https://dev.to/vinimabreu/my-weekend-challenge-entry-an-ai-announcer-for-the-beach-sport-duels-my-futevolei-crew-plays-every-4hhc</link>
      <guid>https://dev.to/vinimabreu/my-weekend-challenge-entry-an-ai-announcer-for-the-beach-sport-duels-my-futevolei-crew-plays-every-4hhc</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" class="crayons-story__hidden-navigation-link"&gt;I gave my futevôlei crew an AI announcer (DuelUp Live)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;DEV Weekend Challenge: Passion Edition Submission&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/vinimabreu" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4010065%2Ff3d37966-fcdb-4c21-9df3-f47b258b99bd.jpeg" alt="vinimabreu profile" class="crayons-avatar__image" width="375" height="640"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/vinimabreu" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Vinicius Pereira
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Vinicius Pereira
                
              
              &lt;div id="story-author-preview-content-4112345" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/vinimabreu" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4010065%2Ff3d37966-fcdb-4c21-9df3-f47b258b99bd.jpeg" class="crayons-avatar__image" alt="" width="375" height="640"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Vinicius Pereira&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 10&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" id="article-link-4112345"&gt;
          I gave my futevôlei crew an AI announcer (DuelUp Live)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/weekendchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;weekendchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/googleaichallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;googleaichallenge&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;10&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              3&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>ai</category>
      <category>llm</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>I gave my futevôlei crew an AI announcer (DuelUp Live)</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Fri, 10 Jul 2026 09:52:42 +0000</pubDate>
      <link>https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60</link>
      <guid>https://dev.to/vinimabreu/i-gave-my-futevolei-crew-an-ai-announcer-duelup-live-4h60</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/weekend-2026-07-09"&gt;Weekend Challenge: Passion Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Every Sunday my futevôlei crew meets on the sand in Niterói, Brazil. The rivalries are real: there are rematches people talk about all week, comebacks nobody is allowed to forget, and at least one duel per month that ends with someone buying açaí for everybody.&lt;/p&gt;

&lt;p&gt;I am so deep into this passion that I have been building a whole platform for it: &lt;strong&gt;DuelUp&lt;/strong&gt;, a competitive system for amateur beach sports, with ELO ratings, rank tiers from Sand to Legend, and virtual-coin stakes on real matches. It is not launched yet, but the crew already lives by its rules.&lt;/p&gt;

&lt;p&gt;This weekend I built the piece every real rivalry deserves and none of us had: &lt;strong&gt;an announcer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DuelUp Live&lt;/strong&gt; is a fight bill for beach-sport duels. You pick the sport (footvolley, beach tennis, tennis or beach volleyball), write in the two sides with their nicknames and ranks, set the moment ("17-16, match point, blazing sun") and the bad blood ("rematch after last Sunday's comeback"). Then you ring the bell, and an electrifying AI sports announcer calls the match live, in English or in Portuguese with a Brazilian announcer voice, the kind we grew up hearing on the radio.&lt;/p&gt;

&lt;p&gt;Gemini writes the play-by-play. ElevenLabs gives it the voice. The rank tiers come straight from DuelUp's real ELO system, and the announcer uses them as drama: when Silver faces Gold, you get an underdog story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://live.duelup.app" rel="noopener noreferrer"&gt;live.duelup.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No signup, nothing to install. Click &lt;strong&gt;⚡ Example&lt;/strong&gt; to load a ready duel (each sport has three, they cycle), then &lt;strong&gt;🔔 RING THE BELL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two things worth knowing while you try it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every ring is a fresh call, written and voiced on the spot. Ring the bell twice on the same duel and you will never hear the same broadcast.&lt;/li&gt;
&lt;li&gt;It takes about 20 seconds, because it genuinely is an LLM writing a script and a TTS model performing it, live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/LvkZolDs4RU"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgkf82z7kw1sn5h1v86l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgkf82z7kw1sn5h1v86l.png" alt="The fight bill" width="800" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqb1utf1nlc3wrr9jm9r5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqb1utf1nlc3wrr9jm9r5.png" alt="The announcer's call with the audio player" width="732" height="1308"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/vinimabreu/duelup-live" rel="noopener noreferrer"&gt;github.com/vinimabreu/duelup-live&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next.js 14, App Router, zero extra runtime dependencies. Two API routes, one for the writing, one for the voice, both plain REST calls with no SDKs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The writing (Gemini).&lt;/strong&gt; The announcer is &lt;code&gt;gemini-flash-latest&lt;/code&gt; with a prompt that treats narration as radio, not prose: present tense, short explosive sentences, 90 to 120 words, written for the ear because a TTS voice will perform it. Two decisions mattered more than the prompt itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thinking disabled&lt;/strong&gt; (&lt;code&gt;thinkingBudget: 0&lt;/code&gt;). An announcer should not overthink. Without this, the model's reasoning tokens were eating the output budget and truncating calls mid-sentence. With it, calls come back complete and fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model cascade.&lt;/strong&gt; If &lt;code&gt;gemini-flash-latest&lt;/code&gt; returns a 503 under load, the route falls through to &lt;code&gt;gemini-flash-lite-latest&lt;/code&gt;, then &lt;code&gt;gemini-2.0-flash&lt;/code&gt;. The demo has already survived a real Google traffic spike this way. A demo that only works when the weather is nice is not a demo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Temperature is 1.0, which is why no two calls are alike. The prompt is also sport-aware: tennis gets called on the court, everything else on the sand, and the rank tiers get woven in as favorite-versus-underdog tension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The voice (ElevenLabs).&lt;/strong&gt; &lt;code&gt;eleven_multilingual_v2&lt;/code&gt; with stability set low, because announcers are not stable people. Portuguese calls go to Eduardo, a Brazilian voice that sounds like he has narrated a thousand matches. English calls go to Charlie, who brings the energy. The waveform player on the bill is wired to the real audio element: real progress, real duration, and the bars dance while he shouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bill.&lt;/strong&gt; The design is a vintage fight poster: paper texture, ink borders, Anton for the headline type, a black promoter plate with the real DuelUp logo, "TALE OF THE TAPE" for the context fields, and the rank badges from the actual game next to each fighter. The whole UI switches between English and Portuguese, announcer included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best use of Google AI&lt;/strong&gt; and &lt;strong&gt;Best use of ElevenLabs.&lt;/strong&gt; They are not decorations here, they are the two halves of the product: Gemini is the announcer's brain, ElevenLabs is his lungs.&lt;/p&gt;




&lt;p&gt;One personal note. Futevôlei is not a theme I picked for a challenge. It is the sport I play every single day, and the crew in this post is my real crew. Getting to spend a weekend building for it, and then writing about it here, was the rare kind of work that does not feel like work.&lt;/p&gt;

&lt;p&gt;The platform came first, but the announcer went live before the platform did. That feels right. Passion projects do not follow roadmaps.&lt;/p&gt;




&lt;p&gt;Built by &lt;strong&gt;Vinicius Pereira&lt;/strong&gt; · &lt;a href="https://vinimabreu.dev" rel="noopener noreferrer"&gt;vinimabreu.dev&lt;/a&gt; · &lt;a href="https://github.com/vinimabreu" rel="noopener noreferrer"&gt;github.com/vinimabreu&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>googleaichallenge</category>
    </item>
    <item>
      <title>Lead Quorum: a multi-agent lead qualifier that refuses to guess (ADK + A2A)</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Wed, 08 Jul 2026 07:35:25 +0000</pubDate>
      <link>https://dev.to/vinimabreu/lead-quorum-a-multi-agent-lead-qualifier-that-refuses-to-guess-adk-a2a-5dom</link>
      <guid>https://dev.to/vinimabreu/lead-quorum-a-multi-agent-lead-qualifier-that-refuses-to-guess-adk-a2a-5dom</guid>
      <description>&lt;p&gt;&lt;em&gt;This post is my submission for &lt;a href="https://dev.to/deved/build-multi-agent-systems"&gt;DEV Education Track: Build Multi-Agent Systems with ADK&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Most lead scoring demos share a failure mode: you paste messy notes, a model returns a confident number, and nobody can answer why it is a 60 and not a 40. I built the opposite. Lead Quorum is a distributed multi-agent qualifier where the number is set by deterministic code, the explanation provably adds up to the score, and when two independent readers disagree about the input, the system abstains instead of guessing.&lt;/p&gt;

&lt;p&gt;Three production failures it kills by construction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Opaque scores.&lt;/strong&gt; Every point is granted and explained on the same line of code, and a test parses the explanation and asserts the named points sum to the score. The reason cannot drift from the number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One model grading its own homework.&lt;/strong&gt; Two readers running two different Gemini models extract the same lead independently, as separate services. Agreement between different models is real signal, not a model agreeing with itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fake confidence on thin input.&lt;/strong&gt; "They mentioned possibly renewing" should not score like "they renewed in March."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What a run looks like. Clear notes, both models agree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CONFIRMED  60/100: +35 team of 30 seats &amp;gt;= 25; +15 reachable decision maker (vp);
           +10 renewed at least once
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Ambiguous notes, the models read "possibly renewing" differently:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXCLUDED   readings disagree on which signals fire (prior_relationship: False vs True);
           abstaining instead of scoring a contradiction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That EXCLUDED is the feature. The score would differ depending on which reading you believe, so the honest output is no score, plus exactly what disagreed. A defensible abstention beats a fake-precise number built on a contradiction.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cloud Run Embed
&lt;/h2&gt;

&lt;p&gt;Open it, paste your own messy lead notes, and watch the audit trail: the score, the reason that reconciles to it, the two independent readings side by side, and the EXCLUDED abstention when they disagree about which rules fire. The two readers are separate Cloud Run services the orchestrator reaches over A2A.&lt;/p&gt;


&lt;div class="ltag__cloud-run"&gt;
  &lt;iframe height="600px" src="https://lq-app-598130840480.us-central1.run.app"&gt;
  &lt;/iframe&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  Your Agents
&lt;/h2&gt;

&lt;p&gt;Five roles. Two are LLMs, three are deterministic code, and that split is the design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enrichment reader&lt;/strong&gt; (LlmAgent, gemini-flash-latest): extracts structured fields from raw notes. Temperature 0, pinned output schema, instructed to leave absent signals at their defaults instead of guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rederive reader&lt;/strong&gt; (LlmAgent, gemini-2.5-flash-lite): reads the same notes from scratch, independently, on a deliberately different model. Runs as its own microservice, exposed over the A2A protocol with ADK's &lt;code&gt;to_a2a()&lt;/code&gt; and consumed through its agent card with &lt;code&gt;RemoteA2aAgent&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoring agent&lt;/strong&gt; (custom BaseAgent, no LLM): applies the rubric. Each rule grants its points and writes its reason in the same branch, and the agent refuses to emit a result whose reason does not reconcile to its score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corroboration agent&lt;/strong&gt; (custom BaseAgent, no LLM): compares the two readings in score-space. Same rules fire, values close: CONFIRMED. Same rules fire, readings drift: REVIEW, score stands, flagged. A rule flips between readings: EXCLUDED, no score, verdict names the flipped rule and both values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestrator&lt;/strong&gt; (SequentialAgent + ParallelAgent): the two readers run concurrently, so wall-clock is one LLM round-trip, then scoring and corroboration run as pure code. Exactly two LLM calls per lead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The readers deploy as independent Cloud Run services and the orchestrator reaches them over A2A, so the second opinion could be swapped for a different vendor or framework tomorrow without touching the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Learnings
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Put the LLM only where judgment lives.&lt;/strong&gt; Multi-agent systems get expensive when every step is a model call. Two parallel calls per lead, everything downstream deterministic and unit-tested, made the system cheaper per unit of trust, not pricier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score-space beats value-space for corroboration.&lt;/strong&gt; Two readings 500 dollars apart on the same side of a threshold produce the same score; that is drift, not contradiction. Comparing which rules fire, instead of raw field equality, lets abstention trigger only when the disagreement actually changes the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation has to be enforced, not intended.&lt;/strong&gt; "Keep the reason next to the points" is a discipline, and disciplines rot. A failing test does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A2A is what makes independence credible.&lt;/strong&gt; Behind an agent card, the second reader is a black box that could be any model, any framework, anywhere. That is the difference between a second opinion and an echo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADK's workflow agents are underrated.&lt;/strong&gt; SequentialAgent and ParallelAgent gave me a deterministic, testable topology with no LLM routing where none was needed. The surprise of the build: RemoteA2aAgent has no output_key, so remote responses land in the event log, and a small capture adapter was the missing piece to keep the distributed pipeline identical to the local one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/vinimabreu/lead-quorum" rel="noopener noreferrer"&gt;github.com/vinimabreu/lead-quorum&lt;/a&gt;, MIT, 16 tests, including a script that runs the full distributed topology locally.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>buildmultiagents</category>
      <category>gemini</category>
      <category>adk</category>
    </item>
    <item>
      <title>Your LLM bill has two sides. Build the ledger that shows both.</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 07 Jul 2026 20:57:08 +0000</pubDate>
      <link>https://dev.to/vinimabreu/your-llm-bill-has-two-sides-build-the-ledger-that-shows-both-p54</link>
      <guid>https://dev.to/vinimabreu/your-llm-bill-has-two-sides-build-the-ledger-that-shows-both-p54</guid>
      <description>&lt;p&gt;Every RAG cost estimate starts the same way: input tokens equal top_k times chunk size, plus some overhead. Most of them stop there too. Then the invoice arrives, it is three times the estimate, and the team spends a sprint tuning chunk sizes while the actual money leaks somewhere that formula never touches.&lt;/p&gt;

&lt;p&gt;The bill for an LLM system is set by total input and output tokens, summed across every call a query triggers. Not per call. Not input only. Every call, both directions. If you only remember one sentence from this post, that is the one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why chunk math lies
&lt;/h2&gt;

&lt;p&gt;Four leaks, in the order I usually find them in real pipelines:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calls you forgot exist.&lt;/strong&gt; A query condenser rewriting the user question. A silent SDK retry. A JSON repair call after a malformed response. An agent loop that took three turns instead of one. Each is a full-priced API call that no chunk formula predicts, and some frameworks make them without telling you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scaffolding re-sent on every call.&lt;/strong&gt; System prompt, tool schemas, formatting instructions, few-shot examples. This fixed overhead rides along on every single call, and in multi-call pipelines it often outweighs the retrieved chunks you are carefully tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The output side, billed at a premium.&lt;/strong&gt; Output tokens cost several times input on every major provider's list price, five times on current Claude models. A dashboard that tracks input tokens is watching the cheap half of the transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-call synthesis re-billing its own draft.&lt;/strong&gt; Refine-style chains regenerate the full running answer at every step. Only the final draft is delivered, but all N drafts are billed at output rates, and each discarded draft is re-sent as input to the next call. The answer component of your cost scales with N, and no chunk tuning touches it. Prompt caching does not rescue it either: the growing answer sits mid-prompt, which breaks the prefix match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop counting tokens. Start reading them.
&lt;/h2&gt;

&lt;p&gt;The fix is not a better estimate. It is refusing to estimate: every provider already returns exact usage on every response, so the whole job is logging what the API tells you and adding it up per query.&lt;/p&gt;

&lt;p&gt;Where you hook matters more than it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The SDK call site&lt;/strong&gt; is the reliable choke point. A thin wrapper around each &lt;code&gt;create()&lt;/code&gt; sees server-reported usage for every call you make, streaming or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework callbacks are leakier.&lt;/strong&gt; Some read server-reported usage properly and propagate through nested calls (LangChain's usage metadata callback does). Others estimate with a local tokenizer instead of reading the wire, LlamaIndex's TokenCountingHandler being the documented example. And none of them see calls made outside the framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP-layer hooks have a streaming blind spot.&lt;/strong&gt; An httpx response hook fires when headers arrive, before the body. For non-streaming calls you can read the body inside the hook; for SSE streams the usage arrives at the end of a body the hook cannot safely consume. If you want wire-level capture for streams, you need a logging proxy or a transport wrapper that tees the stream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most codebases the call-site wrapper is one afternoon of work and covers everything that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger
&lt;/h2&gt;

&lt;p&gt;Columns that earn their keep, learned from what actually gets queried later:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;column&lt;/th&gt;
&lt;th&gt;why it exists&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;query_id&lt;/td&gt;
&lt;td&gt;ties every call in one user request together; propagate it with a contextvar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;call_index&lt;/td&gt;
&lt;td&gt;0, 1, 2... within the query; the column that exposes hidden calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;model&lt;/td&gt;
&lt;td&gt;mixed-model pipelines bill at mixed rates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;input_tokens, output_tokens&lt;/td&gt;
&lt;td&gt;the two sides of the bill, server-reported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cache_read, cache_write&lt;/td&gt;
&lt;td&gt;cached input bills at its own rates; on Anthropic these are two separate fields billed differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stop_reason&lt;/td&gt;
&lt;td&gt;a truncated response often triggers a retry you will want to find&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;latency_ms&lt;/td&gt;
&lt;td&gt;free to record, and cost and latency investigations are usually the same investigation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage&lt;/td&gt;
&lt;td&gt;"condense", "retrieve-answer", "repair"; the label that turns rows into a story&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A minimal implementation, SQLite plus a contextvar:&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;sqlite3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextvars&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;

&lt;span class="n"&gt;query_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ContextVar&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;=&lt;/span&gt; &lt;span class="nc"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tokens.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;CREATE TABLE IF NOT EXISTS calls(
    query_id TEXT, call_index INT, model TEXT,
    input_tokens INT, output_tokens INT,
    cache_read INT, cache_write INT,
    stop_reason TEXT, latency_ms INT, stage TEXT)&lt;/span&gt;&lt;span class="sh"&gt;"""&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;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;qid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query_id&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="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT COUNT(*) FROM calls WHERE query_id=?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO calls VALUES (?,?,?,?,?,?,?,?,?,?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
         &lt;span class="n"&gt;usage&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;usage&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapping a non-streaming Anthropic call:&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;anthropic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&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;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;
    &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stop_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;latency_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;,&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;resp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the contextvar once at the top of each user request (&lt;code&gt;query_id.set(str(uuid.uuid4()))&lt;/code&gt;), pass a &lt;code&gt;stage&lt;/code&gt; label at each call site, and the ledger fills itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The streaming gotchas
&lt;/h2&gt;

&lt;p&gt;Streaming is where naive capture quietly loses data, and the two big providers fail in different directions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI&lt;/strong&gt; only emits usage on a streamed call if you ask for it: pass &lt;code&gt;stream_options={"include_usage": True}&lt;/code&gt;. The usage then arrives on the final chunk, with every earlier chunk carrying &lt;code&gt;usage=None&lt;/code&gt;. Forget the flag and the ledger records nothing while the meter runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anthropic&lt;/strong&gt; splits usage across the stream: &lt;code&gt;message_start&lt;/code&gt; carries the input side, and the output count arrives via &lt;code&gt;message_delta&lt;/code&gt; near the end. If you use the SDK's streaming helper, the accumulated final message carries complete usage, so &lt;code&gt;stream.get_final_message().usage&lt;/code&gt; is the easy path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field names do not agree across providers.&lt;/strong&gt; &lt;code&gt;prompt_tokens&lt;/code&gt; versus &lt;code&gt;input_tokens&lt;/code&gt;, cached tokens under &lt;code&gt;prompt_tokens_details.cached_tokens&lt;/code&gt; on one API and &lt;code&gt;cache_read_input_tokens&lt;/code&gt; on another. And some self-hosted OpenAI-compatible servers omit usage entirely. Normalize into your own schema at write time, not at query time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first week shows you
&lt;/h2&gt;

&lt;p&gt;Having turned this on in production pipelines, the surprises arrive in a reliable order.&lt;/p&gt;

&lt;p&gt;First surprise: &lt;strong&gt;call count.&lt;/strong&gt; A rewrite step someone added in March, a retry that fires on every truncated response, an agent loop budgeted for one turn that averages 2.4. &lt;code&gt;GROUP BY query_id&lt;/code&gt; and the queries with eight rows instead of two are your bill.&lt;/p&gt;

&lt;p&gt;Second: &lt;strong&gt;the scaffolding.&lt;/strong&gt; The fixed prompt overhead, multiplied by the call count you just discovered, frequently outweighs the retrieved context. People tune the 2,000 variable tokens and ignore the 3,500 fixed ones riding on every call.&lt;/p&gt;

&lt;p&gt;Third, and only third: chunk size and top_k, the things everyone tunes first.&lt;/p&gt;

&lt;p&gt;The ledger also makes redundancy visible. If your corpus has near-duplicate documents, they show up as repeating, near-identical input deltas across queries: you are paying to send the model the same paragraphs again and again, and the fix (dedup before the context window) is a quality fix that happens to cut the bill.&lt;/p&gt;

&lt;p&gt;The whole analysis, honestly, is one query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;query_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cost_proxy&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;query_id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;cost_proxy&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 5 is a list-price output-to-input ratio; swap in your provider's. The top 20 rows of that query are worth more than any cost-optimization blog post, including this one.&lt;/p&gt;

&lt;p&gt;The companion package is on GitHub and PyPI: &lt;a href="https://github.com/vinimabreu/token-ledger" rel="noopener noreferrer"&gt;token-ledger&lt;/a&gt; is exactly this ledger, packaged. &lt;code&gt;pip install llm-token-ledger&lt;/code&gt;, zero dependencies including the dashboard, one afternoon.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/sidswirl/how-many-tokens-does-your-rag-stack-actually-send-to-the-llm-4hn6"&gt;Sid Probstein made the argument from the architecture side this week&lt;/a&gt;, comparing what RAG stacks actually send across frameworks, and his closing line is the right summary of the whole subject: that number, not top_k, is your bill.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>tutorial</category>
      <category>python</category>
    </item>
    <item>
      <title>Why is this a 60 and not a 40?</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Sun, 05 Jul 2026 19:10:43 +0000</pubDate>
      <link>https://dev.to/vinimabreu/why-is-this-a-60-and-not-a-40-496h</link>
      <guid>https://dev.to/vinimabreu/why-is-this-a-60-and-not-a-40-496h</guid>
      <description>&lt;p&gt;The worst thirty seconds of my week used to happen in review. Someone senior would put a finger on one row of my output, a row scored 60 out of 100, and ask why it was a 60 and not a 40. And I did not know. Not on the spot.&lt;/p&gt;

&lt;p&gt;What I did instead was open the scoring code, re-read the branches, do the arithmetic in my head against that input row, and reconstruct the answer live while the room waited. Half the time I got it right. The other half I said "let me check and get back to you," which in front of people who make decisions on that number is the same as admitting the number is a guess.&lt;/p&gt;

&lt;p&gt;It took me a couple of those meetings to see the real problem. A score you cannot defend the moment you are asked is not a feature. It is a liability. If the person consuming your output has to trust &lt;em&gt;you&lt;/em&gt; instead of the number, you did not ship a scoring system. You shipped yourself as a dependency, and you will be that dependency at 6pm on a Friday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two code paths, one quiet lie
&lt;/h2&gt;

&lt;p&gt;The failure is almost always the same shape. The scoring function adds points. Somewhere else, usually a different file, a second function assembles the human-readable reason. Both encode the same thresholds. They agree on the day you write them, the tests pass, everyone moves on.&lt;/p&gt;

&lt;p&gt;Then a threshold changes. It changes for a good reason, in the scoring function, and it ships. Nobody touches the reason builder, because why would they. It is in another file, the tests are still green, the diff looked complete. Now the number moves and the story stays put. The reason string is not merely stale. It is confidently, specifically wrong.&lt;/p&gt;

&lt;p&gt;Take one concrete row and hold it fixed for the rest of this post:&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="n"&gt;lead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly_spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seats&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;renewed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the code that scores it, split across the two paths:&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;# BEFORE: the number and its justification live in two functions
# that happen to share a set of magic thresholds.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_lead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly_spend&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;8000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# bumped from 5000 last quarter
&lt;/span&gt;        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seats&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;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;renewed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;reasons&lt;/span&gt; &lt;span class="o"&gt;=&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;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly_spend&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;5000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# nobody bumped this one
&lt;/span&gt;        &lt;span class="n"&gt;reasons&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high spend&lt;/span&gt;&lt;span class="sh"&gt;"&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;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seats&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;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;reasons&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;large team&lt;/span&gt;&lt;span class="sh"&gt;"&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;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;renewed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;reasons&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;renewed before&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run our row through both:&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;score_lead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&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;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;high spend, large team, renewed before&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spend threshold moved to 8000, so 6000 no longer earns the 40 points and the lead lands at 60. But &lt;code&gt;explain&lt;/code&gt; still tests against the old 5000 line, so it leads with "high spend" as the top reason. The number went down because spend stopped counting, and the explanation says it is high because of spend. You are now debugging your own explanation against your own arithmetic, out loud, in front of the person who asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the reason where you add the points
&lt;/h2&gt;

&lt;p&gt;The fix is boring and it is the whole point. Build the reason on the same line where you add the points. Same &lt;code&gt;if&lt;/code&gt;, same condition, same line of sight. The reason is not documentation written after the fact. It is a byproduct of the decision, emitted at the instant the decision is made.&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;# AFTER: one branch owns both the points and the words for them.
# You cannot change the number without seeing the sentence.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_lead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&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;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly_spend&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;8000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
        &lt;span class="n"&gt;parts&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+40 monthly spend &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;monthly_spend&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; &amp;gt;= 8000&lt;/span&gt;&lt;span class="sh"&gt;"&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;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seats&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;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;
        &lt;span class="n"&gt;parts&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+35 team of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;seats&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; seats &amp;gt;= 25&lt;/span&gt;&lt;span class="sh"&gt;"&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;lead&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;renewed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
        &lt;span class="n"&gt;parts&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+25 renewed at least once&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# weights top out at 100, so the cap never bites
&lt;/span&gt;    &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/100: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&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;parts&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no signals fired&lt;/span&gt;&lt;span class="sh"&gt;"&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;final&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same row, one call now:&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;score_lead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;60/100: +35 team of 30 seats &amp;gt;= 25; +25 renewed at least once&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;It is a 60 and not a 40 because team size and a prior renewal fired and spend did not, and the numbers add up in front of you. There is no second file to forget, because there is no second file. The &lt;code&gt;if&lt;/code&gt; that grants 40 points is the only place that can claim the 40.&lt;/p&gt;

&lt;p&gt;One caveat about that cap. Here 40 plus 35 plus 25 lands exactly on 100, so &lt;code&gt;min&lt;/code&gt; never changes the total. If your raw points can overshoot the ceiling, either design the weights to land on the cap, or name the cap in the reason and reconcile against the pre-cap total. The mismatch has to stay visible, never quietly absorbed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test that catches the drift before the meeting does
&lt;/h2&gt;

&lt;p&gt;"Just build it in the same place" is a discipline, and disciplines rot the first sprint you are in a hurry. So I pin it down as a property: the point values named in the reason must sum to the score. If they ever stop summing, the audit trail has started lying, and I want CI to fail instead of a stakeholder to notice.&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;re&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_reason_points_sum_to_score&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;lead&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_LEADS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;score_lead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;named&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\+(\d+)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;named&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test parses its own audit trail and checks the arithmetic against the score the function returned. Add a signal that bumps the score but forget to append its line, and the sum comes up short. Append a line but fat-finger the number, and it comes up wrong. Either way it goes red. Notice what the test does not do: it says nothing about whether the reason is well written. It checks that the reason is not lying. Those are different jobs, and in a review only the second one matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sometimes the defensible score is zero
&lt;/h2&gt;

&lt;p&gt;The pattern pushed me somewhere I did not expect. In &lt;code&gt;crosswatch&lt;/code&gt;, the small harness I pulled this out of, I corroborate every number across two independent providers before scoring anything. Agree, and the region is CONFIRMED. Drift a little, REVIEW. Contradict each other hard, and the region is EXCLUDED and scores zero on purpose.&lt;/p&gt;

&lt;p&gt;Not the average of the two numbers. Zero. When two independent sources disagree that badly, the honest answer to "what is the value here" is "we do not know," and the average of two contradictions looks exactly as confident as a good number while meaning nothing. A zero I can stand behind beats a 55 I cannot, and the EXCLUDED row carries the reason for it: the providers disagreed by this many points, so no score was more honest than one built on a contradiction.&lt;/p&gt;

&lt;p&gt;The same welding buys something operational. Because every reason ships attached to its number, and the raw readings are stored with provenance (which provider, which run, which timestamp), I can rescore months of stored data under new thresholds without collecting anything again. Collection is expensive, judgment is free. Change a weight, rescore in milliseconds, and every reason moves in lockstep with every number, reconciliation test still standing guard.&lt;/p&gt;

&lt;p&gt;So the next time someone points at a row and asks why it is a 60 and not a 40, the answer is already sitting in the cell next to the 60, and a test has already confirmed it adds up. Everything after that is just deciding where your &lt;code&gt;if&lt;/code&gt; statements go.&lt;/p&gt;

&lt;p&gt;crosswatch is on GitHub, MIT licensed, stdlib only, 63 tests: &lt;a href="https://github.com/vinimabreu/crosswatch" rel="noopener noreferrer"&gt;github.com/vinimabreu/crosswatch&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>reliability</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I wrote a free 84-page handbook to take you from "what is a token" to working AI agents</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Fri, 03 Jul 2026 20:29:37 +0000</pubDate>
      <link>https://dev.to/vinimabreu/i-wrote-a-free-84-page-handbook-to-take-you-from-what-is-a-token-to-working-ai-agents-3fe0</link>
      <guid>https://dev.to/vinimabreu/i-wrote-a-free-84-page-handbook-to-take-you-from-what-is-a-token-to-working-ai-agents-3fe0</guid>
      <description>&lt;p&gt;For the past months, whenever someone asked me how to get into AI, I had no good single answer. The material out there is either academic theory, marketing hype, or fifty scattered tutorials that each assume you read the other forty-nine.&lt;/p&gt;

&lt;p&gt;So I wrote the thing I wanted to hand over: &lt;strong&gt;Artificial Intelligence in Practice&lt;/strong&gt;, an 84-page handbook that starts at zero and ends with you running agents with tools. It is free, and it just went up on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/vinimabreu/ai-in-practice" rel="noopener noreferrer"&gt;https://github.com/vinimabreu/ai-in-practice&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who it is for
&lt;/h2&gt;

&lt;p&gt;People starting out in AI, or curious about it, who want one coherent path instead of a pile of tabs. It assumes no AI background. If you can open a terminal, you can follow it. Developers already building with LLMs will find parts of it review, though the evaluation and cost chapters may still earn their time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path it walks
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Foundations.&lt;/strong&gt; What tokens, context windows and temperature actually are, and why a model "hallucinates". Setting up a real dev environment without pain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running models on your own machine.&lt;/strong&gt; Ollama, picking a model family for your task, and honestly sizing what your hardware can run, including Apple Silicon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RAG.&lt;/strong&gt; Making an AI answer from YOUR documents instead of making things up, explained without hand-waving: embeddings, vector databases, chunking, and when RAG is the wrong answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents.&lt;/strong&gt; The loop that turns a chatbot into something that does work: CrewAI, LangGraph, tools, browsing, MCP, and agents from different frameworks talking to each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Going deeper.&lt;/strong&gt; Fine-tuning vs RAG, what the APIs really cost, security and privacy of autonomous agents, prompting as a method, and how to test AI systems so they do not embarrass you in front of a customer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three capstone projects&lt;/strong&gt;, from a document Q&amp;amp;A assistant to a research agent crew, ending with what it takes to go from project to product.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything in it was tested by hand. Prices and version numbers were verified in mid-2026, and the book tells you to re-verify them, because this field does not sit still.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why free
&lt;/h2&gt;

&lt;p&gt;Because the version of me from a few years ago needed this and could not have paid for it. It is under CC BY-NC-SA: share it, translate it, use it to teach a course. Just keep it non-commercial and credit the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two small asks
&lt;/h2&gt;

&lt;p&gt;If you know someone who keeps saying "I want to learn AI but I do not know where to start", send them the link. That is exactly who it was written for.&lt;/p&gt;

&lt;p&gt;And if it is useful to you, &lt;strong&gt;a star on the repo&lt;/strong&gt; helps other people find it: &lt;a href="https://github.com/vinimabreu/ai-in-practice" rel="noopener noreferrer"&gt;https://github.com/vinimabreu/ai-in-practice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I will be serializing some of the deeper chapters here on dev.to over the next weeks, starting with the one I think is most neglected: how to actually test an LLM system. If there is a chapter you want first, tell me in the comments.&lt;/p&gt;

&lt;p&gt;Vinicius Pereira&lt;/p&gt;

</description>
      <category>ai</category>
      <category>beginners</category>
      <category>opensource</category>
      <category>learning</category>
    </item>
    <item>
      <title>You can't debug a RAG you didn't instrument</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Wed, 01 Jul 2026 18:25:40 +0000</pubDate>
      <link>https://dev.to/vinimabreu/you-cant-debug-a-rag-you-didnt-instrument-15gf</link>
      <guid>https://dev.to/vinimabreu/you-cant-debug-a-rag-you-didnt-instrument-15gf</guid>
      <description>&lt;p&gt;Every few weeks someone opens a ticket that says some version of "I think the AI is getting worse?" The answers are still fluent, still confident, still cited. They're just subtly wrong, often enough that people notice and rarely enough that nothing obviously breaks. Then a few days quietly disappear into it.&lt;/p&gt;

&lt;p&gt;The instinct is always to look at the model or the prompt. Almost every time I've chased one of these, the model did exactly what it was told. It read the top documents and answered from them. The problem was upstream, in what got retrieved and handed to it, and the reason it took days to find is that the retrieval step was a black box. We log the final answer. Sometimes we log the citations. We almost never log what the retriever actually saw and chose between.&lt;/p&gt;

&lt;p&gt;You can't debug what you didn't instrument.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually log
&lt;/h2&gt;

&lt;p&gt;For every answer, I keep a small retrieval manifest next to it. Three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What was retrieved.&lt;/strong&gt; The whole candidate set with scores, not just the ones that got cited. This is the part you'd expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What was excluded, and why.&lt;/strong&gt; Each dropped candidate with a reason code: below the rank cutoff, filtered out by metadata, superseded or stale, out of license, deduplicated. This is the part nobody logs, and it's exactly where the blind spots live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What was cited.&lt;/strong&gt; What actually made it into the answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is roughly the shape of one entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"what is our refund window for enterprise?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retrieved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"policy-2024-11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.86&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cited"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"policy-2026-05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cited"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"excluded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"policy-2026-05-draft"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status:superseded"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sales-deck-q1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"below_rank_cutoff"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that for a second. The cited document is fourteen months old and scored higher than the current one, purely because it happened to be written more cleanly. In the answer, that is invisible. In the manifest, it is the first thing you see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it buys you
&lt;/h2&gt;

&lt;p&gt;Two things that used to be guesswork become mechanical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can tell a reasoning problem from an evidence problem.&lt;/strong&gt; When two runs disagree, or two deployments of the same model give different answers, diff the manifests first. Same evidence set and different answers means it is the model or nondeterminism. Different evidence sets means it is retrieval, and you were never going to fix that by tweaking the prompt. Right now most people debug this backwards, staring at the outputs, because the boundary was never captured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stale-document bug surfaces in minutes instead of days.&lt;/strong&gt; The classic failure, where an outdated doc quietly outranks the current one, does not show up in the answer at all. It shows up immediately in the manifest as a top result with an old timestamp. You stop guessing and start reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people get wrong
&lt;/h2&gt;

&lt;p&gt;The exclusion log is noisy. You are not going to read it on every query, and if you try you will drown. So log it always, surface it only when an answer gets flagged or when two results disagree. It is a black box recorder, not a dashboard.&lt;/p&gt;

&lt;p&gt;The other trap is drift. The manifest only helps if the retrieval code emits it as it runs. The moment you rebuild it after the fact, or maintain it by hand, it becomes one more thing that can quietly disagree with reality, and now you are debugging your debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;Citations tell you what supported the answer. The exclusion log tells you what the answer was blind to. You need both to trust the thing, and almost everyone keeps only the first.&lt;/p&gt;

&lt;p&gt;Most "the model is hallucinating" tickets are really "the retriever handed it the wrong evidence and it used it faithfully." Instrument the boundary and the model stops being the default suspect. That is the direction I have been building rag-quality around, the idea that the retrieval step should measure and report on itself instead of being trusted on faith.&lt;/p&gt;

&lt;p&gt;So I am curious: what do you actually log from your retriever today? Just the citations, the full candidate set, or nothing until something breaks?&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>llm</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Sonnet 5 dropped today. Watch the other hand.</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 30 Jun 2026 22:04:06 +0000</pubDate>
      <link>https://dev.to/vinimabreu/sonnet-5-dropped-today-watch-the-other-hand-p50</link>
      <guid>https://dev.to/vinimabreu/sonnet-5-dropped-today-watch-the-other-hand-p50</guid>
      <description>&lt;p&gt;Sonnet 5 landed today and everyone's busy benchmarking it. Fair, it looks like a strong, cheap, very agentic model, close to Opus 4.8 for a fraction of the cost. But the most important model news this month isn't Sonnet 5. It's that Fable 5, Anthropic's most powerful model, has been switched off worldwide since June 12, and it's still down.&lt;/p&gt;

&lt;p&gt;Not rate-limited. Not deprecated. Export-controlled. Anthropic got an emergency US Commerce Department directive citing national security after a way to jailbreak Fable 5's safeguards surfaced, and since they couldn't verify the nationality of every request in real time, they pulled it for everyone, Americans included. Mythos 5 got a partial reprieve for a handful of cyber-defense orgs. Fable 5 is still dark.&lt;/p&gt;

&lt;p&gt;Sit with that. The most capable model on the market right now isn't gated by price, it's gated by law. "Available to everyone who can pay" quietly became "available to whoever the government allows." That's a different universe to build a company in.&lt;/p&gt;

&lt;p&gt;Now look at what shipped in the same window: Sonnet 5, explicitly the cheaper, more agentic, good-enough model. The industry's answer to "the frontier just got pulled" is "you probably didn't need the frontier anyway." Which, honestly, has been true for most of us for a while. Most production AI fails on reliability, eval, and retrieval, not on raw model IQ. A bigger brain was never the thing standing between your demo and prod.&lt;/p&gt;

&lt;p&gt;So the take: chasing the newest frontier was always a weak moat, and now it's a fragile one, it can be switched off by a government letter on a Tuesday, it literally just was. The teams that win the next year are the ones who (a) made their systems reliable enough to run a cheaper or open model without flinching, and (b) didn't bet the company on a single vendor in a single jurisdiction. Reliability and independence beat raw capability the moment capability becomes a policy decision.&lt;/p&gt;

&lt;p&gt;Tell me I'm wrong. Is Sonnet 5 a real step change for your use case, or is the actual headline that the frontier now ships with an export-control letter attached?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>llm</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What changed since the last scrape? A small change-detection layer (stdlib only)</title>
      <dc:creator>Vinicius Pereira</dc:creator>
      <pubDate>Tue, 30 Jun 2026 20:23:53 +0000</pubDate>
      <link>https://dev.to/vinimabreu/what-changed-since-the-last-scrape-a-small-change-detection-layer-stdlib-only-6lc</link>
      <guid>https://dev.to/vinimabreu/what-changed-since-the-last-scrape-a-small-change-detection-layer-stdlib-only-6lc</guid>
      <description>&lt;p&gt;Most of my scrapers answer one question: what's on the site right now. But that's almost never the question I actually have. What I care about is what changed since the last run. A new listing showed up, a price dropped, a product disappeared, a status flipped from open to closed. The current snapshot on its own doesn't tell me any of that.&lt;/p&gt;

&lt;p&gt;For a while I rebuilt the same thing on every project: load last run's JSON, compare it to this run, work out what's new, what's gone, and what changed. It's never hard, but it's fiddly, and I kept getting the same details wrong (more on that below). So I pulled it into one small reusable piece and stopped rewriting it. It's called scrape-sentinel.&lt;/p&gt;

&lt;p&gt;This post is about the design more than the tool, because the interesting part is the handful of decisions that make change detection annoying to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;You give it the records from this run and a key, and it tells you what was added, removed, and changed since last time. For changed records, it tells you which fields moved and from what to what.&lt;/p&gt;

&lt;p&gt;The diff itself is a plain function with no I/O:&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;from&lt;/span&gt; &lt;span class="n"&gt;scrape_sentinel&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;

&lt;span class="n"&gt;cs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous_records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignore_fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scraped_at&lt;/span&gt;&lt;span class="sh"&gt;"&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;added&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&gt;"&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;changed&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;changed&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;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which prints something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new: W-104
W-101 price 39.0 -&amp;gt; 35.0
W-101 in_stock True -&amp;gt; False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The details that kept biting me
&lt;/h2&gt;

&lt;p&gt;A few decisions are the whole reason this is worth extracting instead of rewriting inline every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match by key, not by position.&lt;/strong&gt; This is the big one. If you diff two lists positionally, a re-sorted page or a reordered API response looks like every row changed. Matching on a stable key (one field or a few) means a reordered run shows zero changes, which is correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first run is a baseline.&lt;/strong&gt; With no previous snapshot, everything looks new. The first run just records state and stays quiet instead of firing an alert for all 4,000 items.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignore the noisy fields.&lt;/strong&gt; A scraped_at timestamp or a session token changes every single run. You drop those from the comparison, or restrict it to an allow-list of fields you actually care about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write snapshots atomically.&lt;/strong&gt; The state file is written to a temp file and renamed, so a run that dies halfway can't leave you with a corrupted snapshot that breaks the next comparison.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using it for real
&lt;/h2&gt;

&lt;p&gt;In practice you want the diff plus the I/O around it: load the last snapshot, run your scraper, compare, alert, save the new snapshot.&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;from&lt;/span&gt; &lt;span class="n"&gt;scrape_sentinel&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;CallableSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PipelineConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SnapshotStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ConsoleAlerter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WebhookAlerter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_once&lt;/span&gt;&lt;span class="p"&gt;,&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;scrape&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;list&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="c1"&gt;# your requests / Playwright / API code, returns a list of dicts
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch_products&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PipelineConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ignore_fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scraped_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;alerters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;ConsoleAlerter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;catalog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)),&lt;/span&gt;
        &lt;span class="nc"&gt;WebhookAlerter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SLACK_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;catalog&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CallableSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scrape&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;SnapshotStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./.state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# {'added': 1, 'removed': 1, 'changed': 1, 'unchanged': 2}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alerts go to the console, a webhook (Slack or Telegram), or a JSON change log. There's also a CLI with a --fail-on-change exit code, so you can put it on a cron job or a CI step and have the next step run only when something actually moved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scrape-sentinel run &lt;span class="nt"&gt;--source&lt;/span&gt; json:catalog.json &lt;span class="nt"&gt;--key&lt;/span&gt; sku &lt;span class="nt"&gt;--state&lt;/span&gt; ./.state &lt;span class="nt"&gt;--webhook&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLACK_WEBHOOK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it is not
&lt;/h2&gt;

&lt;p&gt;It's not a scraper. It doesn't crawl anything for you. You bring your own requests, Playwright, or API client and hand it a list of dicts, and it owns the diff, the alert, and the snapshot. It's also standard library only, no dependencies, so dropping it into an existing project doesn't pull in a tree. The diff being a pure function is what made it easy to test heavily, which is where most of the suite lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;p&gt;MIT licensed: &lt;a href="https://github.com/vinimabreu/scrape-sentinel" rel="noopener noreferrer"&gt;https://github.com/vinimabreu/scrape-sentinel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Honestly curious how other people handle this. Do you diff inside the database, keep snapshots on disk like this, hash each record, or something cleaner? It feels like the kind of thing everyone quietly rebuilds, so I'd like to know what I missed.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
