<?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: Zack Thompson</title>
    <description>The latest articles on DEV Community by Zack Thompson (@zackthompson317).</description>
    <link>https://dev.to/zackthompson317</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%2F4204781%2F13a67e90-3653-4154-a44d-e7f06f44d074.png</url>
      <title>DEV Community: Zack Thompson</title>
      <link>https://dev.to/zackthompson317</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zackthompson317"/>
    <language>en</language>
    <item>
      <title>Catch Prompt Regressions Before They Ship: Testing an Invoice-Extraction Prompt with Promptfoo and GitHub Actions</title>
      <dc:creator>Zack Thompson</dc:creator>
      <pubDate>Sun, 25 Oct 2026 04:06:13 +0000</pubDate>
      <link>https://dev.to/zackthompson317/catch-prompt-regressions-before-they-ship-testing-an-invoice-extraction-prompt-with-promptfoo-and-3me9</link>
      <guid>https://dev.to/zackthompson317/catch-prompt-regressions-before-they-ship-testing-an-invoice-extraction-prompt-with-promptfoo-and-3me9</guid>
      <description>&lt;p&gt;&lt;em&gt;This article was written with help from an AI assistant; all code was tested and the text reviewed. Example repo: &lt;a href="https://github.com/zackthompson317/promptfoo-invoice-extraction-ci" rel="noopener noreferrer"&gt;https://github.com/zackthompson317/promptfoo-invoice-extraction-ci&lt;/a&gt; (Promptfoo 0.125.0, Node 22.22+)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a prompt that breaks quietly
&lt;/h2&gt;

&lt;p&gt;Say your accounts-payable tool sends every incoming invoice to an LLM and asks for JSON: invoice number, vendor, date, currency, line items, subtotal, tax and total. It works. Then someone "improves" the prompt so it handles a new vendor's layout, and three weeks later finance notices that every euro invoice has been booked in dollars.&lt;/p&gt;

&lt;p&gt;Nothing crashed. The output was still valid JSON, and nobody's unit tests touched the prompt, because prompts usually live outside the test suite. That's the gap this tutorial closes. We'll put a prompt under the same kind of regression testing as code, using Promptfoo's declarative test cases, and run it on every pull request with GitHub Actions. The build fails if quality drops.&lt;/p&gt;

&lt;p&gt;By the end you'll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;promptfooconfig.yaml&lt;/code&gt; that compares two prompt versions side by side&lt;/li&gt;
&lt;li&gt;20 synthetic invoices as test cases, including edge cases&lt;/li&gt;
&lt;li&gt;layered assertions: valid JSON, a JSON schema, exact field checks, and a custom JavaScript check that the numbers actually add up&lt;/li&gt;
&lt;li&gt;a GitHub Actions workflow that blocks merges when the pass rate falls below 95%&lt;/li&gt;
&lt;li&gt;a setup that runs with &lt;strong&gt;no API key and no cost&lt;/strong&gt; by default, so you can try the whole pipeline before you spend anything&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Project layout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompts/v1.txt              # the original, terse prompt
prompts/v2.txt              # the "improved" prompt under review
invoices/inv01.txt..inv20.txt
tests.yaml                  # one test case per invoice
schema.json                 # JSON schema for the extraction output
asserts/totals-add-up.js    # custom arithmetic check
providers/local-extractor.js# offline stand-in model for $0 CI
promptfooconfig.yaml
.github/workflows/prompt-eval.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: The prompts
&lt;/h2&gt;

&lt;p&gt;Version 1 is what most teams write first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Extract the invoice below as JSON with keys invoice_number, vendor, date (YYYY-MM-DD),
currency (ISO code), line_items [{description, quantity, unit_price}], subtotal, tax, total.
Return JSON only.

{{invoice}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Version 2 adds the constraints we learned the hard way: no code fences, currency inferred from the symbol, plain numbers, and tolerance for a &lt;code&gt;TOTAL&lt;/code&gt; label as well as &lt;code&gt;Total Due&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Promptfoo treats &lt;code&gt;{{invoice}}&lt;/code&gt; as a Nunjucks variable, and each test case fills it in. Listing both files under &lt;code&gt;prompts:&lt;/code&gt; makes every test run against both versions, so the results grid becomes a before/after comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Test data you can share
&lt;/h2&gt;

&lt;p&gt;Real invoices contain names, addresses and bank details, so they shouldn't go into a public repo or a CI log. The example repo uses 20 generated invoices with fake vendors and random line items. Tax is 7%, and the totals are computed, so they're guaranteed to be correct. Two invoices are in euros, and two are deliberate edge cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;inv19&lt;/code&gt; labels the total &lt;code&gt;TOTAL&lt;/code&gt; instead of &lt;code&gt;Total Due&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inv20&lt;/code&gt; starts with a chatty note before the invoice header&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Edge cases are where prompt changes usually break, so give them their own tests rather than hoping the happy path covers them. As you find production failures, add a sanitized copy of each one to &lt;code&gt;invoices/&lt;/code&gt; and it becomes a permanent regression test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Assertions in layers
&lt;/h2&gt;

&lt;p&gt;A single "looks right" check isn't enough. The suite stacks four assertions on each test, from cheap and general to strict and specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Valid JSON matching a schema.&lt;/strong&gt; Promptfoo's &lt;code&gt;is-json&lt;/code&gt; assertion takes an optional JSON schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;is-json&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file://schema.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema requires every field, restricts &lt;code&gt;currency&lt;/code&gt; to &lt;code&gt;USD&lt;/code&gt; or &lt;code&gt;EUR&lt;/code&gt;, requires a &lt;code&gt;YYYY-MM-DD&lt;/code&gt; date, and needs at least one line item. That catches code fences, prose before the JSON, missing keys, and &lt;code&gt;"$1,234.00"&lt;/code&gt; strings where numbers belong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Exact field checks.&lt;/strong&gt; For each invoice we know the right answer, so we assert it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;javascript&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;const o = JSON.parse(output);&lt;/span&gt;
    &lt;span class="s"&gt;return o.invoice_number === 'INV-1007' &amp;amp;&amp;amp; o.currency === 'EUR' &amp;amp;&amp;amp; Math.abs(o.total - 1043.29) &amp;lt; 0.01;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the assertion that would have caught the euro bug in the opening story. Compare floats with a tolerance, never &lt;code&gt;===&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Internal consistency.&lt;/strong&gt; A model can copy the total correctly and still invent a line item. &lt;code&gt;asserts/totals-add-up.js&lt;/code&gt; recomputes the math:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unit_price&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtotal&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tax&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;totals reconcile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`sum &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; vs subtotal &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;{pass, score, reason}&lt;/code&gt; instead of a bare boolean means the Promptfoo viewer shows &lt;em&gt;why&lt;/em&gt; a case failed, which saves a lot of time when you're debugging twenty red cells.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Rubric grading for fuzzy fields (optional).&lt;/strong&gt; Some fields don't have one right string. "Acme Supplies" and "Acme Supplies Inc." are both acceptable vendor names. Promptfoo's &lt;code&gt;llm-rubric&lt;/code&gt; assertion asks a grading model to judge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm-rubric&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;The vendor field names the same company as the "From:" line, ignoring suffixes like Inc or LLC.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;llm-rubric&lt;/code&gt; needs a real grading model and an API key, so the example repo leaves it off by default. Turn it on once you add a provider key (see Step 6).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: A $0 provider for CI
&lt;/h2&gt;

&lt;p&gt;Here's a practical problem: you want the pipeline in place on day one, but you may not want to put an API key in CI yet, or pay for evals on every documentation PR. Promptfoo supports custom providers, which are plain JavaScript classes with a &lt;code&gt;callApi&lt;/code&gt; method, so the repo ships a deterministic stand-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LocalExtractor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;id&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local-extractor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;callApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INVOICE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="c1"&gt;// ...regex extraction of number, vendor, date, line items, totals...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&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="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LocalExtractor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's wired in with &lt;code&gt;id: file://providers/local-extractor.js&lt;/code&gt;. To be clear, this doesn't measure model quality. It parses the invoice text out of the rendered prompt. What it does show is that your config, tests, schema, custom assertions and CI gate all work, for free, before any model is involved. It's also a useful baseline: if a real model scores below a regex parser on clean synthetic data, you'll want to know.&lt;/p&gt;

&lt;p&gt;Running it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx promptfoo@0.125.0 &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; promptfooconfig.yaml &lt;span class="nt"&gt;--no-cache&lt;/span&gt;
npx promptfoo@0.125.0 view
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is a grid with one row per invoice and one column per prompt. With the local provider, all 40 cells (20 tests Ã— 2 prompts) pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Gate pull requests in GitHub Actions
&lt;/h2&gt;

&lt;p&gt;The workflow runs whenever a PR touches prompts, tests, invoices, assertions or providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prompts/**'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;promptfooconfig.yaml'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tests.yaml'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;invoices/**'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;asserts/**'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;providers/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;22.22&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx --yes promptfoo@0.125.0 eval -c promptfooconfig.yaml --no-cache -o results.json&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;PROMPTFOO_DISABLE_TELEMETRY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail if pass rate &amp;lt; 95%&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;node -e "const r=require('./results.json').results.stats;&lt;/span&gt;
          &lt;span class="s"&gt;const rate=r.successes/(r.successes+r.failures);&lt;/span&gt;
          &lt;span class="s"&gt;console.log('pass rate',rate); if(rate&amp;lt;0.95) process.exit(1)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;promptfoo-results&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;results.json&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few design choices are worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin versions.&lt;/strong&gt; Both the Promptfoo version and the Node version are pinned. Recent Promptfoo releases need Node 22.22 or newer, and an unpinned &lt;code&gt;npx promptfoo&lt;/code&gt; will eventually change under you. Pin the model version too, once you add one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a threshold, not perfection.&lt;/strong&gt; LLM output varies. With a real model, a strict 100% gate turns into a flaky build that people learn to ignore. Start at 95% and tighten it as the suite settles. Keep &lt;code&gt;temperature: 0&lt;/code&gt; for extraction tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the artifact.&lt;/strong&gt; Uploading &lt;code&gt;results.json&lt;/code&gt; even on failure means reviewers can download the exact outputs that failed without re-running anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off caching in CI&lt;/strong&gt; (&lt;code&gt;--no-cache&lt;/code&gt;), so a passing cached result can't hide a regression after a provider-side model update.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Switching to a real model
&lt;/h2&gt;

&lt;p&gt;When you're ready, uncomment a provider in &lt;code&gt;promptfooconfig.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file://providers/local-extractor.js&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local-extractor&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openai:gpt-4.1-mini&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;temperature&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the key as a repository secret (&lt;code&gt;OPENAI_API_KEY&lt;/code&gt;) and pass it through the workflow's &lt;code&gt;env&lt;/code&gt;. Keeping the local extractor alongside the real model gives you a stable baseline column in every run. Now &lt;code&gt;llm-rubric&lt;/code&gt; can be turned on too.&lt;/p&gt;

&lt;p&gt;On cost: 20 short invoices Ã— 2 prompts Ã— 1 model is 40 small requests per run, so with a mini-sized model a run costs well under a cent. If that adds up across many PRs, the &lt;code&gt;paths:&lt;/code&gt; filter already skips runs for changes that can't affect prompt behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do when a run fails
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the uploaded &lt;code&gt;results.json&lt;/code&gt; artifact, or run &lt;code&gt;promptfoo view&lt;/code&gt; locally, and filter to failures.&lt;/li&gt;
&lt;li&gt;Read the &lt;code&gt;reason&lt;/code&gt; string from the custom assertion. "sum 812.40 vs subtotal 912.40" points straight at a dropped line item.&lt;/li&gt;
&lt;li&gt;Decide whether the prompt regressed or the test is wrong. Fixed synthetic data makes that easy, because the expected values come from the generator and aren't hand-typed.&lt;/li&gt;
&lt;li&gt;If a production invoice exposed a new failure mode, add a sanitized version as a new test &lt;em&gt;before&lt;/em&gt; fixing the prompt. Watch it fail, then fix the prompt and watch it pass.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Most of the value is in tests 2 and 3.&lt;/strong&gt; Schema validation catches formatting problems, but the field-level and arithmetic checks catch the silent, expensive failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge cases earn their keep.&lt;/strong&gt; In practice, the label-variant and noisy-preamble invoices are the ones that flip when prompts change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make it cheap, then make it real.&lt;/strong&gt; A no-key provider gets the pipeline merged on day one. Adding the model is a three-line change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twenty tests is a starting point, not a target.&lt;/strong&gt; Grow the suite from real failures rather than inventing hypothetical ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Clone the repo, run &lt;code&gt;npx promptfoo@0.125.0 eval&lt;/code&gt;, then try breaking &lt;code&gt;prompts/v2.txt&lt;/code&gt;, for example by deleting the currency instruction, and watch the gate catch it.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ai</category>
      <category>githubactions</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
