<?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: Gajanand V Dhayagode</title>
    <description>The latest articles on DEV Community by Gajanand V Dhayagode (@gajanand_vdhayagode_55da).</description>
    <link>https://dev.to/gajanand_vdhayagode_55da</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%2F4048190%2Fe08fb3af-9e92-4f47-b0ca-f6509f7b2c5e.jpg</url>
      <title>DEV Community: Gajanand V Dhayagode</title>
      <link>https://dev.to/gajanand_vdhayagode_55da</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gajanand_vdhayagode_55da"/>
    <language>en</language>
    <item>
      <title>My fine-tuned Gemma blamed a critical haemoglobin on "how the sample was handled". My eval gave it a pass.</title>
      <dc:creator>Gajanand V Dhayagode</dc:creator>
      <pubDate>Mon, 19 Oct 2026 06:58:31 +0000</pubDate>
      <link>https://dev.to/gajanand_vdhayagode_55da/my-fine-tuned-gemma-blamed-a-critical-haemoglobin-on-how-the-sample-was-handled-my-eval-gave-it-50jn</link>
      <guid>https://dev.to/gajanand_vdhayagode_55da/my-fine-tuned-gemma-blamed-a-critical-haemoglobin-on-how-the-sample-was-handled-my-eval-gave-it-50jn</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge: Week 2 — Side Hustle&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;In India, your lab report usually arrives as a PDF on WhatsApp, hours or days before you see a doctor. So people ask the health professional who's always there, the &lt;strong&gt;pharmacist at the counter&lt;/strong&gt;: &lt;em&gt;"Is this bad?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Report Saathi&lt;/strong&gt; (&lt;em&gt;saathi&lt;/em&gt; means "companion") answers that question. You upload the PDF (or a photo), say whether you're pregnant, and within a few minutes (about one for a short report, three to five for a full check-up) you get a short explanation in plain English:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the values &lt;strong&gt;worth asking your doctor about&lt;/strong&gt;, and what each test measures;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;small differences&lt;/strong&gt; grouped in one line, so a full check-up shows a handful of things instead of sixteen alarms;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;lab's own notes&lt;/strong&gt;, quoted as the lab's words;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;neutral questions to take to your doctor&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;a "contact your doctor today" line, &lt;strong&gt;only&lt;/strong&gt; for critical values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It &lt;strong&gt;never diagnoses&lt;/strong&gt; and &lt;strong&gt;never mentions medicines.&lt;/strong&gt; Every answer ends: &lt;em&gt;"This explains your report. It is not a diagnosis. Please show this report to your doctor."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The side hustle:&lt;/strong&gt; a pharmacy puts a QR code on the counter. Customers scan it and upload their report, and each upload is counted for that shop. The shop pays a small monthly fee. The customer gets an answer, and the pharmacist gets their time back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The niche: pregnancy.&lt;/strong&gt; Pregnancy changes what "normal" means. Haemoglobin, TSH and the sugar test all have different ranges in each trimester. An explainer that uses adult ranges will tell a pregnant woman her normal result is low. Report Saathi asks about pregnancy (reports never say), uses the trimester range, and tells her when a value is normal &lt;em&gt;only because&lt;/em&gt; she's pregnant.&lt;/p&gt;

&lt;p&gt;This post is mostly about one lesson, though: &lt;strong&gt;a reassuring excuse is worse than no reason at all&lt;/strong&gt;, and my own fine-tune taught me that.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;See a finished example right away (no upload, no waiting):&lt;/strong&gt; &lt;a href="https://report-saathi.onrender.com/example" rel="noopener noreferrer"&gt;https://report-saathi.onrender.com/example&lt;/a&gt;&lt;br&gt;
It walks through the three steps on a made-up report: what the reader found, what the rules decided, and the explanation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try your own report:&lt;/strong&gt; &lt;a href="https://report-saathi.onrender.com" rel="noopener noreferrer"&gt;https://report-saathi.onrender.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Measured on the live app with real (redacted) full check-ups of 83–87 rows: &lt;strong&gt;read in 117–150 s, explained in 69–126 s, 3–5 minutes in all.&lt;/strong&gt; A short report takes about a minute.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/gajanand27-05" rel="noopener noreferrer"&gt;
        gajanand27-05
      &lt;/a&gt; / &lt;a href="https://github.com/gajanand27-05/report-saathi" rel="noopener noreferrer"&gt;
        report-saathi
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Report Saathi: understand your lab report&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Report Saathi&lt;/strong&gt; (&lt;em&gt;saathi&lt;/em&gt; means "companion") explains a blood-test report in simple English. You upload the PDF your lab sent on WhatsApp, or a photo of the paper report, and say whether you're pregnant. A few seconds later you see:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;which values are &lt;strong&gt;worth asking your doctor about&lt;/strong&gt;, and what each test measures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;small differences&lt;/strong&gt; that usually don't matter on their own, grouped in one line&lt;/li&gt;
&lt;li&gt;your &lt;strong&gt;normal&lt;/strong&gt; results in one line&lt;/li&gt;
&lt;li&gt;the lab's own comments, quoted as the lab's words&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;neutral questions to ask your doctor&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;an urgent "contact your doctor today" line, &lt;strong&gt;only&lt;/strong&gt; for critical values&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It &lt;strong&gt;never diagnoses&lt;/strong&gt; and &lt;strong&gt;never suggests medicines, supplements or doses&lt;/strong&gt;. Every answer ends with: &lt;em&gt;"This explains your report. It is not a diagnosis. Please show this report to your doctor."&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Built for the DEV "Hacktoberfest Open-Source AI Challenge: Side Hustle" (15–19 October 2026). Measured…&lt;/p&gt;
&lt;/blockquote&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/gajanand27-05/report-saathi" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The web app deploys to Render with one click (free plan, no card). The worker runs on any machine with an OpenAI-compatible server (we use vLLM on a DGX Spark). &lt;code&gt;rules.csv&lt;/code&gt;, the table that decides what's high or low, is plain text that a doctor can check line by line.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;phone ──upload──► web app on Render ◄── polls (outbound only) ── worker on a DGX Spark
                  job queue (Postgres)                            1. READER     Gemma 4 12B reads the report
                  deletes everything after 24 h                   2. RULES      rules.csv flags each value (no AI)
   ◄── explanation streams back ───┘                              3. EXPLAINER  the same 12B + our LoRA explains
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first decision was the important one: &lt;strong&gt;no AI decides whether a value is abnormal.&lt;/strong&gt; A model that says "your TSH is fine" when it isn't is the worst thing this app could do. So the job is split in three, and the AI never makes the medical call.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The reader has to prove every number
&lt;/h3&gt;

&lt;p&gt;Indian lab PDFs usually have a text layer, but it's jumbled: columns interleave, ranges float above their test, and one test can print five ranges (child, adult, three trimesters). So Gemma 4 gets each page as an image &lt;em&gt;plus&lt;/em&gt; that page's exact text, and returns the rows.&lt;/p&gt;

&lt;p&gt;Then one rule makes it trustworthy: &lt;strong&gt;every number Gemma returns must appear in the PDF's own text.&lt;/strong&gt; If it doesn't, the row becomes "couldn't read" and is never explained. Photos have no text layer, so the app shows the numbers it read and asks &lt;em&gt;"Does this match your report?"&lt;/em&gt; before explaining anything.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;27 real reports&lt;/strong&gt; (names, IDs, doctors and lab removed; used only for testing, never for training), 969 rows in all, I tested two readers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Gemma 4 31B&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Gemma 4 12B (live)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High/low results the user sees&lt;/td&gt;
&lt;td&gt;960 / 960 right&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;958 / 960 right&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Values exactly right&lt;/td&gt;
&lt;td&gt;963 / 969&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;966 / 969&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Values it made up&lt;/td&gt;
&lt;td&gt;1, caught by the PDF check&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time per report&lt;/td&gt;
&lt;td&gt;median 37 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;median 110 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 31B reads better and faster. The live app uses the 12B anyway, because &lt;strong&gt;one model does both jobs&lt;/strong&gt;: the 12B's own weights read the report, and the same server applies our LoRA to write the explanation. The 31B was only ever the teacher, and the GPU box is shared with other projects, so one 12B instead of two big models frees about 46 GB for everyone else.&lt;/p&gt;

&lt;p&gt;The cost is real and I'd rather show it: one of the 12B's two misses turned a &lt;strong&gt;low HDL into "Normal"&lt;/strong&gt;, most likely because it misread the printed range on a crowded page. I tried a safety net that cross-checks every value against our own table, but on correct reports the two disagree on 2% of rows (mostly small red-cell indices), so it would have raised false alarms. That's the first thing to fix next.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The rules: a table a doctor can check
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;rules.csv&lt;/code&gt; lists every test the way labs print it ("SGPT", "ALT (SGPT)"…), with units, ranges, &lt;strong&gt;pregnancy ranges by trimester&lt;/strong&gt;, critical limits and an importance level. The rules use &lt;strong&gt;the lab's printed range first&lt;/strong&gt;, pick the one that matches the patient's age or trimester, and handle bands ("5.7–6.4 = pre-diabetic") and values that fall in the gap between them.&lt;/p&gt;

&lt;p&gt;The real reports kept teaching it. The first 6 were thyroid, HbA1c and antenatal panels. The next 21 brought urine tests, dengue, typhoid (Widal), HIV and hepatitis screens and urine cultures: &lt;strong&gt;194 rows the table didn't know.&lt;/strong&gt; Now it knows all of them, with care. A positive screening test is "a screening result your doctor will confirm", never "you have HIV". A culture report keeps the germ's name but &lt;strong&gt;drops the antibiotic list&lt;/strong&gt;, so the explainer never sees a medicine name.&lt;/p&gt;

&lt;p&gt;The rules also gave me the most embarrassing bug of the week. This lab prints platelets as &lt;strong&gt;"373 lakhs/cmm"&lt;/strong&gt;, which would be 100× too many. It means 373 thousand. My rules converted it correctly to 3.73 lakh, then attached a note saying &lt;em&gt;"read as thousand/cmm"&lt;/em&gt;. Put that next to 3.73 and a patient reads &lt;strong&gt;3,730: a dangerously low count.&lt;/strong&gt; The rules were right and the note was wrong. The blind review below caught it; the note now gives both numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The explainer, and the excuse it learned
&lt;/h3&gt;

&lt;p&gt;The explainer only sees the flagged rows. I wanted a small, fast model, so the plan was &lt;strong&gt;Gemma 4 31B as the teacher and Gemma 4 12B as the student&lt;/strong&gt;, fine-tuned on the team's DGX Spark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The base 12B wasn't safe enough.&lt;/strong&gt; It skipped the doctor questions on all three critical cases in my first test set, and it told two patients they had a "urinary tract infection". That's a diagnosis, which this app must never make.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So I made training data.&lt;/strong&gt; A generator writes synthetic reports in real lab formats (40% pregnant, lots of critical values and odd cases). The rules flag them and the 31B explains each one. &lt;strong&gt;An answer is kept only if it passes every automatic check&lt;/strong&gt; (no diagnosis, no medicine words, every flagged number mentioned, the urgent line only when critical) &lt;strong&gt;and a review by the 31B as a judge.&lt;/strong&gt; Real reports are never used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fine-tune 1 copied the teacher's filler.&lt;/strong&gt; It learned the format perfectly, and it also learned &lt;em&gt;"this can happen for several general health reasons."&lt;/em&gt; True, and useless. So I wrote a rule against vagueness: give &lt;strong&gt;"concrete, everyday, non-alarming reasons"&lt;/strong&gt;, &lt;em&gt;for example a recent meal, dehydration, the time of day, how the sample was handled.&lt;/em&gt; I added the same examples to the judge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fine-tune 2 looked like a win.&lt;/strong&gt; 2,351 of 3,500 teacher answers passed, ft2 trained on 2,000 of them (a 16-bit LoRA on &lt;code&gt;gemma-4-12B-it&lt;/code&gt;), and on my 36-case test (each case run twice) it beat everything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Passes checks + judge (of 72)&lt;/th&gt;
&lt;th&gt;Rule breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 12B, base&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B, base (the teacher)&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;12B, fine-tune 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;63&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A fine-tuned 12B beating its own teacher, with zero rule breaks. I nearly made that the headline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the answers were read blind.&lt;/strong&gt; I gave 13 shuffled pairs (fine-tune 2 vs the 31B, same report) to a separate AI model as a reviewer, without telling it which was which. &lt;strong&gt;The 31B won 9, the fine-tune 3, one tie.&lt;/strong&gt; The reasons were the problem:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Haemoglobin 6.2, critically low, 32 weeks pregnant&lt;/strong&gt; (fine-tune 2): &lt;em&gt;"This can happen due to the body's increased needs during pregnancy or **how the sample was handled.&lt;/em&gt;&lt;em&gt;"&lt;/em&gt;&lt;br&gt;
&lt;strong&gt;A positive HIV screening test&lt;/strong&gt; (fine-tune 2): &lt;em&gt;"This can happen **if the sample was handled in a certain way&lt;/em&gt;* or if there is a false positive result."*&lt;br&gt;
&lt;strong&gt;An HbA1c just above normal&lt;/strong&gt;, a 3-month average of blood sugar (fine-tune 2): &lt;em&gt;"This can happen due to **a recent meal, the time of day&lt;/em&gt;* the sample was taken…"*&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;All three passed my eval.&lt;/strong&gt; They're calm, specific and everyday, which is exactly what I'd asked for. The HbA1c one is simply false: a 3-month average doesn't care about breakfast. The other two are worse. They hand a frightened person the comfortable reading of a result that needs a doctor &lt;em&gt;today&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;My example list had become the answer. And the student learned it harder than the teacher. Counting the excuses in the same 72 answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;"time of day"&lt;/th&gt;
&lt;th&gt;"how the sample was handled"&lt;/th&gt;
&lt;th&gt;"all other tests were normal" on a one-test report&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 12B, base&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B (teacher)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;12B, fine-tune 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A fine-tune doesn't just copy the teacher. It &lt;strong&gt;amplifies whatever the filter rewards&lt;/strong&gt;, and my filter rewarded reassurance. The judge was the 31B grading with my examples, so it marked the excuses as good answers. Scoring 63 of 72 measured how well ft2 had learned my rubric, not whether it explained well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix was to the rubric, not the model.&lt;/strong&gt; Prompt v1.4 says: give &lt;strong&gt;a real, common cause for &lt;em&gt;that&lt;/em&gt; test, or none at all&lt;/strong&gt;. For example, low MCV is commonly linked to low iron or an inherited trait; HbA1c isn't changed by a meal. Critical values and positive screening tests get &lt;strong&gt;no reassuring reason&lt;/strong&gt;. The urgent line moves to straight after the overview. New automatic checks catch "how the sample was handled", meal excuses for HbA1c, and invented "all other tests are normal" lines. The judge now fails a wrong reason instead of rewarding it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then I re-tested both models with prompt v1.4 and the stricter judge&lt;/strong&gt; (same 72 answers, no retraining):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Passes (of 72)&lt;/th&gt;
&lt;th&gt;Rule breaks&lt;/th&gt;
&lt;th&gt;"time of day"&lt;/th&gt;
&lt;th&gt;"sample"&lt;/th&gt;
&lt;th&gt;invented "all other tests normal"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;12B, fine-tune 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same fine-tune, given honest instructions, dropped the habit it had learned hardest: "time of day" went from 30 answers to 0. The critical haemoglobin now reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Please contact your doctor today about your Haemoglobin."&lt;/em&gt; (straight after the overview), then: &lt;em&gt;"Haemoglobin… is 6.2 g/dL. This is critically low. The normal range is different for people in their third trimester of pregnancy."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No excuse at all. And the low MCV that used to be blamed on the time of day now gets &lt;em&gt;"Small red cells are commonly linked to low iron."&lt;/em&gt; The 31B's two rule breaks were both "urinary tract infection" on urine tests, a diagnosis. So &lt;strong&gt;fine-tune 2 writes the explanations in the live app&lt;/strong&gt;, with prompt v1.4.&lt;/p&gt;

&lt;p&gt;A second blind read of these new answers is next.&lt;/p&gt;

&lt;p&gt;There wasn't time to retrain before the deadline. A fine-tune 3 on v1.4 data is next, and this time I'll read its answers blind before I believe its score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed:&lt;/strong&gt; Gemma 4's FP8 serving with its MTP drafter makes the 12B &lt;strong&gt;5× faster&lt;/strong&gt; (3.4 s against 17 s per explanation). It cost the base 12B nothing in quality, but it cost the fine-tune 4 passes and 3 rule breaks, so I didn't take the trade. The text streams in as it's written anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  One deploy surprise
&lt;/h3&gt;

&lt;p&gt;I planned a paid Render setup with an hourly clean-up job. My Indian debit card was declined (&lt;em&gt;"does not support this type of purchase"&lt;/em&gt;), so it runs on Render's &lt;strong&gt;free plan&lt;/strong&gt;. With no cron, the app deletes jobs older than 24 hours itself whenever the worker checks in, and the worker's polling keeps the free service awake. The free Postgres worked on the first deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What still doesn't work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The reasons look fixed on the test, but the habit is still in the weights.&lt;/strong&gt; Prompt v1.4 holds it back; a fine-tune 3 trained on v1.4 answers would remove it. Two answers still mention the sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counting on big reports.&lt;/strong&gt; On a 70-value check-up the models sometimes miscount what's worth asking about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No doctor has reviewed it yet.&lt;/strong&gt; A gynaecologist is reviewing the pregnancy ranges and critical limits. Until then, &lt;code&gt;rules.csv&lt;/code&gt; is carefully researched, not clinically signed off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 12B is slow on big reports.&lt;/strong&gt; A full 84-row check-up takes about 2 minutes to read and 1–2 minutes to explain (3–5 minutes in all on the live app); the explanation streams in as it's written. Serving the 12B in FP8 with Gemma 4's drafter would be about 5× faster, which I measured, but the fine-tune lost quality in FP8.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real report broke the live app twice last night&lt;/strong&gt; (once on the explainer's context limit, once on a missing range label in the rules). Both are fixed on the GPU box; the second fix still has to reach the public code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One lab.&lt;/strong&gt; All 27 real reports use one Bangalore lab's template. Other labs will break the reader in new ways.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built this with &lt;strong&gt;Claude Code&lt;/strong&gt; as my pair programmer. A second Claude Code session ran the DGX through a private repo: I wrote task files, it pushed results. So every number above came from a script, and the one that fooled me came from my own rubric.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lab reports are some of the most private documents people have.&lt;/strong&gt; They show pregnancies, HIV tests and diabetes. With open weights, &lt;strong&gt;every model runs on a machine our team owns.&lt;/strong&gt; No report goes to an AI company, and Render deletes everything within 24 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could find the bug in my own rubric.&lt;/strong&gt; With open weights I could train my own judge-filtered data, count every excuse in every answer, and compare the student with its teacher. A closed API would have handed me a score, not a habit to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing per report.&lt;/strong&gt; Thousands of teacher answers, two fine-tunes and six-model evaluations, with no API bill. That's also what makes a small monthly fee per pharmacy workable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The honest trade-off:&lt;/strong&gt; a big closed model would probably have given better medical reasons on day one. I chose privacy and paid for it in engineering. This week, that engineering is the story.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 does all the AI work. In the live app &lt;strong&gt;one Gemma 4 12B does both jobs&lt;/strong&gt;: its own weights read every report (page image plus text, each number checked against the PDF), and our &lt;strong&gt;16-bit LoRA&lt;/strong&gt; on the same server writes every explanation. &lt;strong&gt;Gemma 4 31B taught and judged&lt;/strong&gt; the fine-tune. We fine-tuned Gemma 4 12B with a 16-bit LoRA, measured FP8 and the MTP drafter, and found out, by reading blind, what the scores were hiding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render.&lt;/strong&gt; Render hosts the front end, the job queue (web service + Postgres from one &lt;code&gt;render.yaml&lt;/code&gt;, free plan) and an example page that works even when the GPU box is busy. The GPU worker only ever connects outward to Render, so it needs no open ports.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Got a lab report on your phone right now? That's exactly who this is for. And if you're grading a model with its own teacher, read some of its answers blind. 🩺&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>render</category>
    </item>
    <item>
      <title>My AI field guide called castor a "Virginia Creeper". A 107-line list fixed more than fine-tuning did.</title>
      <dc:creator>Gajanand V Dhayagode</dc:creator>
      <pubDate>Sun, 11 Oct 2026 17:48:05 +0000</pubDate>
      <link>https://dev.to/gajanand_vdhayagode_55da/my-ai-field-guide-called-castor-a-virginia-creeper-a-107-line-list-fixed-more-than-fine-tuning-3l01</link>
      <guid>https://dev.to/gajanand_vdhayagode_55da/my-ai-field-guide-called-castor-a-virginia-creeper-a-107-line-list-fixed-more-than-fine-tuning-3l01</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge: Week 1 — Touch Grass&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Pocket Naturalist&lt;/strong&gt; is an offline field guide for walks. You photograph a plant, bird or bug, and &lt;strong&gt;Gemma 4 12B, running on my own laptop&lt;/strong&gt;, tells you what it probably is. It also points out one detail to look at more closely, gives you a small quest to find next ("find a plant with compound leaves"), and writes a journal of your walk at the end.&lt;/p&gt;

&lt;p&gt;There's no cloud and no account, and it works with &lt;strong&gt;no mobile signal&lt;/strong&gt;: the phone talks to the laptop over a hotspot. You can snap photos live, or, if you don't want to carry a laptop around a garden (I didn't), take photos with your normal camera and &lt;strong&gt;add them from the gallery afterwards&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The rule I gave myself: &lt;strong&gt;the screen should be the shortest part of the walk.&lt;/strong&gt; The answer is read aloud, and the phone goes back in your pocket.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;👀 &lt;strong&gt;Shows its reasoning&lt;/strong&gt;: the features it saw, its best guess, and what else it &lt;em&gt;could&lt;/em&gt; be.&lt;/li&gt;
&lt;li&gt;🔁 &lt;strong&gt;Takes a second look&lt;/strong&gt; when it's unsure: it describes the plant's structure first, then names it.&lt;/li&gt;
&lt;li&gt;🔍 &lt;strong&gt;Mystery finds&lt;/strong&gt;: when it's still unsure, it gives you &lt;strong&gt;one thing to check on the real plant&lt;/strong&gt; ("blue flowers mean butterfly pea…") instead of a confident wrong answer.&lt;/li&gt;
&lt;li&gt;✏️ &lt;strong&gt;"I know what this is"&lt;/strong&gt;: correct it, and the journal gives you the credit.&lt;/li&gt;
&lt;li&gt;🌿 &lt;strong&gt;An Indian field guide&lt;/strong&gt;: the fix that made it actually useful where I live. More on that below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&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%2F6z8ze02yhbpdb8m3lxx2.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%2F6z8ze02yhbpdb8m3lxx2.png" alt="Parthenium and Castor cards" width="702" height="1600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Two weeds Gemma got wrong before the field guide. Notice the "look closer" tips: they only describe what's actually in the photo.&lt;/em&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%2Fru749a70v5i5wy86zo6b.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%2Fru749a70v5i5wy86zo6b.png" alt="Norfolk Island pine card" width="800" height="907"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;It was "Sago Palm" before the field guide.&lt;/em&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%2Fer79rxc5yswjwd480umf.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%2Fer79rxc5yswjwd480umf.png" alt="Mystery find card with a how-to-tell clue" width="702" height="1600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;When Gemma isn't sure, it doesn't guess. It tells you what to check on the real plant.&lt;/em&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%2Fk6b8qy9q2yyaysujhttn.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%2Fk6b8qy9q2yyaysujhttn.png" alt="Walk journal" width="774" height="1600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The walk journal, written by Gemma from my 17 finds. It uses Gemma's own names, mistakes included (that's not teak), which is exactly why the app shows "could also be" and lets you correct it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On my real 17-photo walk, here's what changed after the fix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plant&lt;/th&gt;
&lt;th&gt;Stock Gemma 4&lt;/th&gt;
&lt;th&gt;With the field guide&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Castor&lt;/td&gt;
&lt;td&gt;Virginia Creeper&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Castor&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Curry leaf&lt;/td&gt;
&lt;td&gt;Chinese Fringe Tree&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Curry leaf&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Congress grass&lt;/td&gt;
&lt;td&gt;Common Ragweed&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Parthenium&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Four o'clock flower&lt;/td&gt;
&lt;td&gt;"Mexicaner" &lt;em&gt;(not a plant)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Four o'clock flower&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Black nightshade&lt;/td&gt;
&lt;td&gt;Wild Jasmine&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Black nightshade&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Araucaria&lt;/td&gt;
&lt;td&gt;Sago Palm&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Norfolk Island / Cook pine&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Red rose&lt;/td&gt;
&lt;td&gt;Adenium&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Rose&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Overall: 4 → 10 correct out of 15&lt;/strong&gt; photos where I know the right answer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/gajanand27-05" rel="noopener noreferrer"&gt;
        gajanand27-05
      &lt;/a&gt; / &lt;a href="https://github.com/gajanand27-05/pocket-naturalist" rel="noopener noreferrer"&gt;
        pocket-naturalist
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Offline field guide for walks: snap a plant or bird, local Gemma 4 identifies it and sends you on the next quest. Hacktoberfest 2026 Touch Grass.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🌿 Pocket Naturalist&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;An offline field guide for walks. Snap a plant, bird or bug on your phone. &lt;strong&gt;Gemma 4&lt;/strong&gt;, running on a laptop in your backpack, tells you what it probably is, what to look at more closely, and gives you a small quest to find next. When you finish, it writes a journal of your walk.&lt;/p&gt;
&lt;p&gt;You don't need mobile signal, a cloud API or an account. Your photos never leave your own devices.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05" rel="nofollow"&gt;Hacktoberfest 2026 Open-Source AI Challenge, Week 1: "Touch Grass"&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;phone (browser) ──same Wi-Fi or hotspot, no internet──▶ laptop: server.py ──▶ Ollama ──▶ gemma4:12b (vision)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start a walk.&lt;/strong&gt; You get your first quest, read aloud by the phone's built-in offline voice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;📷 Snap a find&lt;/strong&gt;, or &lt;strong&gt;🖼️ add photos from your gallery&lt;/strong&gt; later. You don't have to carry the laptop: take photos in the garden with…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/gajanand27-05/pocket-naturalist" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;It's one standard-library Python file plus one HTML page, with nothing to &lt;code&gt;pip install&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma4:12b
python server.py   &lt;span class="c"&gt;# prints a URL to open on your phone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;The setup:&lt;/strong&gt; phone browser → Wi-Fi hotspot (no internet) → &lt;code&gt;server.py&lt;/code&gt; on my laptop → Ollama → &lt;code&gt;gemma4:12b&lt;/code&gt;. The laptop is nothing special: an &lt;strong&gt;RTX 3050 with 6 GB of GPU memory&lt;/strong&gt; and 16 GB of RAM. Gemma 12B doesn't fully fit, so it runs partly on the GPU and partly on the CPU, at about 30–40 seconds per photo. Every answer is forced into a fixed JSON format with Ollama's &lt;code&gt;format&lt;/code&gt; option, so the app never has to guess what the model meant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Day 1: making it honest
&lt;/h3&gt;

&lt;p&gt;The first thing I learned is that a vision model can be &lt;strong&gt;confidently wrong&lt;/strong&gt;. A house sparrow came back as &lt;em&gt;"Jungle Babbler, 95% sure."&lt;/em&gt; A "fun fact" about hibiscus was made up, and so were some of the scientific names. So I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;made Gemma &lt;strong&gt;list what it sees first&lt;/strong&gt;, then rank three candidates, then choose;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;removed fun facts and scientific names&lt;/strong&gt; and replaced them with "look closer" tips that may only describe what's &lt;em&gt;visible in the photo&lt;/em&gt;;&lt;/li&gt;
&lt;li&gt;told the journal to use &lt;strong&gt;only the walk data&lt;/strong&gt;. Its first draft described "the afternoon sun hanging low" on a walk it knew nothing about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then a vine on my railing came back as "Creeping Charlie". I went outside to look: &lt;strong&gt;it had bright blue flowers. Butterfly pea.&lt;/strong&gt; That moment became a feature: when Gemma isn't sure, the app now gives you a &lt;em&gt;clue to check on the real plant&lt;/em&gt; instead of a guess. That sends you back to the plant instead of back to the screen, which is the whole point of "Touch Grass".&lt;/p&gt;

&lt;h3&gt;
  
  
  Day 2: the real walk, and a bias I didn't expect
&lt;/h3&gt;

&lt;p&gt;I walked my neighbourhood and took 17 photos of ordinary plants: castor, moringa, curry leaf, a four o'clock bush, roadside weeds. Stock Gemma got &lt;strong&gt;4 of 15&lt;/strong&gt; right.&lt;/p&gt;

&lt;p&gt;The interesting part was &lt;em&gt;how&lt;/em&gt; it was wrong. For the castor plant, Gemma described &lt;em&gt;"palmately lobed leaves, deeply serrated edges, large star-shaped leaf"&lt;/em&gt;. That's a perfect description of castor. Then it named it &lt;strong&gt;"Virginia Creeper"&lt;/strong&gt;, an American vine. It did the same everywhere: moringa became "Purslane", curry leaf became "Chinese Fringe Tree", and a four o'clock flower became "Mexicaner", which isn't a plant at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Its eyes were fine. Its vocabulary was American.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 1: a field guide in plain text
&lt;/h3&gt;

&lt;p&gt;Real field guides are regional, so I wrote one: &lt;code&gt;field_guide.csv&lt;/code&gt;, a list of &lt;strong&gt;107 common Indian plants&lt;/strong&gt; from gardens, kitchens, roadsides, waste ground and parks. Each entry has the features that tell it apart. For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Castor: shrub; very large star-shaped palmate leaves with 7–11 toothed lobes; reddish stems; spiny seed clusters&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It goes into Gemma's prompt with three rules I learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sort it A–Z.&lt;/strong&gt; In my first version, "Holy basil" was the first entry, and Gemma started calling half my photos holy basil.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plants only.&lt;/strong&gt; Without that rule, the house sparrow became a "lark".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Never force a match."&lt;/strong&gt; If nothing fits, give your own best name with lower confidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also had to keep the prompt short enough to fit Ollama's default &lt;strong&gt;4,096-token&lt;/strong&gt; context. When I raised it, my 16 GB laptop ran out of memory and the app server was killed. Dropping the local-language names from the prompt (they stay in the CSV for people) fixed that.&lt;/p&gt;

&lt;p&gt;Measured on &lt;strong&gt;28 labelled photos&lt;/strong&gt; (22 of mine, 6 from the internet):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Correct (first answer)&lt;/th&gt;
&lt;th&gt;My garden photos&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stock Gemma 4 12B&lt;/td&gt;
&lt;td&gt;13/28&lt;/td&gt;
&lt;td&gt;8/22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;+ field guide&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20/28&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16/22&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I stopped tuning the list after one revision, so I wasn't just fitting it to my own test photos.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2: fine-tuning on a DGX Spark (and why it lost)
&lt;/h3&gt;

&lt;p&gt;I also had access to an &lt;strong&gt;NVIDIA DGX Spark&lt;/strong&gt;, so I tried the "proper" fix too: a LoRA fine-tune of Gemma 4 12B on &lt;strong&gt;1,193 photos from &lt;a href="https://data.mendeley.com/datasets/748f8jkphb" rel="noopener noreferrer"&gt;DIMPSAR&lt;/a&gt;&lt;/strong&gt;, an open dataset of Indian plant photos from Karnataka and Kerala (Pushpa B.R. &amp;amp; N. Shobha Rani, &lt;em&gt;Data in Brief&lt;/em&gt; 2023, CC BY 4.0).&lt;/p&gt;

&lt;p&gt;To keep the app's output format, each training answer was Gemma's &lt;em&gt;own&lt;/em&gt; answer with only the species name corrected. I trained a LoRA on the language layers (rank 16, one epoch, about 70 minutes), merged it, and patched the weights into Ollama's official &lt;code&gt;gemma4:12b&lt;/code&gt; file so it kept working with images.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;em&gt;(measured on the DGX)&lt;/em&gt;&lt;/th&gt;
&lt;th&gt;Stock&lt;/th&gt;
&lt;th&gt;Fine-tuned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;28 test photos&lt;/td&gt;
&lt;td&gt;12/28&lt;/td&gt;
&lt;td&gt;13/28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DIMPSAR validation&lt;/td&gt;
&lt;td&gt;16/100&lt;/td&gt;
&lt;td&gt;20/100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confidence&lt;/td&gt;
&lt;td&gt;0.6–0.95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.0 on every photo&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;+1 photo, and it became 100% sure of everything&lt;/strong&gt;, which would switch off the app's "second look" and Mystery finds. Two lessons I'm taking with me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the scale of numbers you copy into training data.&lt;/strong&gt; 4% of Gemma's own answers said &lt;code&gt;"confidence": 85&lt;/code&gt; instead of &lt;code&gt;0.85&lt;/code&gt;. The app had quietly clamped those, so nobody noticed until the fine-tuned model said 1.0 for everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In structured output, the loss is mostly the long text.&lt;/strong&gt; Validation loss fell from 1.30 to 0.33, but name accuracy only went from 16% to 20%. The species name is a few tokens next to four descriptive sentences.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the app ships with stock Gemma 4 plus the field guide. &lt;strong&gt;A 107-line text file beat 70 minutes on a DGX Spark.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Other models I measured
&lt;/h3&gt;

&lt;p&gt;On my first 13 test photos: &lt;strong&gt;Gemma 4 12B 10/13&lt;/strong&gt;, Qwen3.5 9B 9/13, MiniCPM-V 4.5 8/13, Qwen2.5-VL 3B 8/13 (it said "100% sure" about almost everything), and Gemma 4 12B QAT on llama.cpp 7/13. That last one was faster, but adding Gemma 4's new &lt;strong&gt;MTP drafter&lt;/strong&gt; gave no speed-up on a 6 GB GPU, because when the model is split between GPU and CPU, checking the draft costs as much as it saves. Every model did fine on clean internet photos. &lt;strong&gt;Real, messy garden photos are where Gemma pulled ahead.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What still doesn't work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Some plants are still wrong:&lt;/strong&gt; moringa (called curry leaf; both have small leaflets), a ZZ plant, an Indian almond tree and one rose plant. That's why the app always shows "could also be", Mystery finds and the ✏️ correction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Birds and insects:&lt;/strong&gt; the field guide doesn't cover them yet. My one bird test is still a miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its confidence is a hint, not a measurement.&lt;/strong&gt; It says 85% for right and wrong answers alike.&lt;/li&gt;
&lt;li&gt;My test labels are careful judgements, not a botanist's. A couple of photos I left out of the score because I couldn't identify them myself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built this with &lt;strong&gt;Claude Code&lt;/strong&gt; as my pair programmer, including the scoring scripts that made every decision above a measured one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this project, open isn't a nice-to-have. Every fix above depended on it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It works where there's no signal.&lt;/strong&gt; Gardens and parks are where mobile data is weakest. The phone only needs a local link to the laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My photos stay mine.&lt;/strong&gt; Photos of my street and garden, with times attached, are location data. They never leave my phone and laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing per photo.&lt;/strong&gt; I ran &lt;strong&gt;hundreds of test photos&lt;/strong&gt; through Gemma while building this, with no API bill and no rate limit. A closed API would have made that kind of measuring expensive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could look inside and choose the fix.&lt;/strong&gt; With an open model I could try prompts, swap models, quantize, add an MTP drafter, and &lt;strong&gt;fine-tune the weights myself&lt;/strong&gt;, then measure which actually worked. The answer (a text file beat a fine-tune) is only something you can learn when you're allowed to try both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone can make it local.&lt;/strong&gt; &lt;code&gt;field_guide.csv&lt;/code&gt; is a plain file. Someone in Kerala, Kenya or Kansas can replace it with their own region's plants, and the same open model becomes &lt;em&gt;their&lt;/em&gt; field guide. That's the part I'm most excited about: open models plus open, editable local knowledge.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 12B (open weights) runs locally through Ollama and does all of the vision identification, the second-look reasoning, the quests and the walk journal. I also fine-tuned it (LoRA on a DGX Spark) and tested its MTP drafter and QAT version along the way.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Now close the laptop and go and see what's growing by your gate. 🌿&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>ai</category>
    </item>
    <item>
      <title>We made SigNoz's LLM observability actually reachable…</title>
      <dc:creator>Gajanand V Dhayagode</dc:creator>
      <pubDate>Sun, 26 Jul 2026 17:01:23 +0000</pubDate>
      <link>https://dev.to/gajanand_vdhayagode_55da/we-made-signozs-llm-observability-actually-reachable-ldk</link>
      <guid>https://dev.to/gajanand_vdhayagode_55da/we-made-signozs-llm-observability-actually-reachable-ldk</guid>
      <description>&lt;p&gt;-and added the one signal it was missing&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Track 01 — AI &amp;amp; Agent Observability.&lt;/strong&gt; A hackathon project built from scratch, July 20–26, 2026.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's a sentence we said to each other on day one and then kept saying all week, because it kept being true:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An API 200 is not evidence that a feature works.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is the story of a project that lives entirely inside that sentence. It's about a feature SigNoz already shipped that almost nobody can reach, a real bug that makes part of it impossible, and a missing signal — &lt;em&gt;measured answer quality&lt;/em&gt; — that we built on top. And it's about how we used SigNoz not as a black box but as the thing under test.&lt;/p&gt;




&lt;h2&gt;
  
  
  The surprising starting point: SigNoz already built the machinery
&lt;/h2&gt;

&lt;p&gt;Most "add observability to LLMs" projects start from zero. Ours didn't, and that's the interesting part.&lt;/p&gt;

&lt;p&gt;SigNoz &lt;strong&gt;v0.133.0&lt;/strong&gt; already ships a complete LLM-observability product in open source: a dedicated frontend module, two custom OpenTelemetry collector processors (an LLM-pricing processor and a span-mapper processor), and CRUD APIs behind them. The machinery to ingest &lt;code&gt;gen_ai.*&lt;/code&gt; spans, price them, normalize vendor-specific shapes, and render them on a dashboard is &lt;em&gt;there&lt;/em&gt;, in the OSS repo, today.&lt;/p&gt;

&lt;p&gt;So our thesis wasn't "SigNoz is missing LLM observability." It was sharper and, we think, more useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SigNoz shipped the machinery. We make the shipped feature actually reachable — and add the one signal it's missing: measured answer quality.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That framing mattered to us ethically, too. Nothing in this project reads "SigNoz is broken." It reads: they built something good, three defaults and one bug stand between a self-hoster and it, and here's the fix — upstreamed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three silent gates
&lt;/h2&gt;

&lt;p&gt;Here's what actually stands between a fresh &lt;code&gt;docker compose up&lt;/code&gt; and a working LLM-observability feature. Every one of them fails &lt;em&gt;silently&lt;/em&gt; — the backend returns success while the frontend hides the result, and nothing tells you why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gate 1 — &lt;code&gt;enable_ai_observability&lt;/code&gt; is default-disabled
&lt;/h3&gt;

&lt;p&gt;The whole feature sits behind an experimental flag that ships off. With it off, the UI route for LLM observability &lt;strong&gt;silently redirects to &lt;code&gt;/home&lt;/code&gt;&lt;/strong&gt;. No error, no "coming soon," no toast. You click the thing and you're just... somewhere else. At the time we hit it, there were &lt;em&gt;zero&lt;/em&gt; web results for the flag name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gate 2 — &lt;code&gt;use_dashboard_v2&lt;/code&gt; is default-disabled
&lt;/h3&gt;

&lt;p&gt;The seeded LLM dashboards are v2 dashboards. Import one over the API with this flag off and it succeeds — &lt;code&gt;200 OK&lt;/code&gt; — and then renders as an empty &lt;strong&gt;"Welcome to your new dashboard"&lt;/strong&gt; shell. The data is flowing. The panels are defined. The screen is blank.&lt;/p&gt;

&lt;p&gt;This is where the day-one sentence was born. Both gates let the backend succeed while the frontend hides the result. We got bitten by &lt;em&gt;both, in one afternoon&lt;/em&gt;, before we understood the pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix for gates 1 and 2 is to ship both flags together — and it has to be both:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SIGNOZ_FLAGGER_CONFIG_BOOLEAN_ENABLE__AI__OBSERVABILITY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="nv"&gt;SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__DASHBOARD__V2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting only one still gives you an empty shell — the exact failure we're fixing. So any "dashboards-as-code" deliverable that sets one flag is quietly reproducing the bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gate 3 — the registries ship empty
&lt;/h3&gt;

&lt;p&gt;Two registries back the feature — &lt;code&gt;llm_pricing_rules&lt;/code&gt; (model prices) and &lt;code&gt;span_mapper_groups&lt;/code&gt; (vendor→spec mapping presets). &lt;strong&gt;Both ship with zero rows.&lt;/strong&gt; No seeded prices, no mapping presets. And there's a nasty second-order trap here: SigNoz's own CrewAI docs tell users to instrument with OpenInference — which emits &lt;em&gt;zero&lt;/em&gt; &lt;code&gt;gen_ai.*&lt;/code&gt; attributes — so a user who follows the docs faithfully lands on a seeded dashboard that shows nothing, and has no way to know the mapping layer is what's missing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one real defect: cost is impossible regardless of configuration
&lt;/h2&gt;

&lt;p&gt;The three gates are configuration. This one is a bug.&lt;/p&gt;

&lt;p&gt;SigNoz's LLM-pricing processor and span-mapper processor are generated into the collector config correctly — we verified the rendered YAML. But neither of them is ever &lt;strong&gt;wired into a pipeline&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GenerateCollectorConfigWithLLMPricingProcessor&lt;/code&gt; and &lt;code&gt;GenerateCollectorConfigWithSpanMapperProcessor&lt;/code&gt; both write only &lt;code&gt;collectorConf["processors"]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Neither touches &lt;code&gt;service.pipelines.traces.processors&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In OpenTelemetry Collector terms, that's &lt;em&gt;dead config&lt;/em&gt;: a processor that exists but is in no pipeline never runs. So even with the flag on and a correctly-rendered &lt;code&gt;gpt-4o&lt;/code&gt; pricing rule, the effective traces pipeline was still &lt;code&gt;[signozspanmetrics/delta, batch]&lt;/code&gt;, and ClickHouse showed &lt;strong&gt;no cost attributes&lt;/strong&gt; — not &lt;code&gt;_signoz.gen_ai.total_cost&lt;/code&gt;, not &lt;code&gt;_signoz.gen_ai.cost_input&lt;/code&gt; / &lt;code&gt;cost_output&lt;/code&gt; (those are the real emitted names; there is no &lt;code&gt;gen_ai.usage.cost&lt;/code&gt; output attribute).&lt;/p&gt;

&lt;p&gt;The good news: the codebase already does this right somewhere. The &lt;strong&gt;logs&lt;/strong&gt; feature wires its generated processor straight into the pipeline. So the fix is small, it follows in-repo prior art, and it's the kind of change a maintainer can verify in two minutes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;enable &lt;code&gt;enable_ai_observability&lt;/code&gt; → add a pricing rule → observe the effective traces pipeline is still &lt;code&gt;[signozspanmetrics/delta, batch]&lt;/code&gt; → cost attributes never appear.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We filed it upstream as &lt;strong&gt;&lt;a href="https://github.com/SigNoz/signoz/pull/12268" rel="noopener noreferrer"&gt;SigNoz/signoz#12268&lt;/a&gt;&lt;/strong&gt; — a small, focused change that cites the prior-art file and ships a clean reproduction. A teammate's fix, not a hackathon drive-by.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part we're proudest of: measuring whether the answers are any &lt;em&gt;good&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Everything above makes the shipped feature reachable. But there's a signal SigNoz's LLM product genuinely does not have, and it's the one that matters most for agents in production: &lt;strong&gt;is the model's answer any good, and did the last release make it worse?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cost, latency, and token counts tell you the model &lt;em&gt;ran&lt;/em&gt;. They say nothing about whether it was &lt;em&gt;right&lt;/em&gt;. So we built an eval layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Emitting a signal SigNoz has never seen
&lt;/h3&gt;

&lt;p&gt;OpenTelemetry specs an evaluation signal — &lt;code&gt;gen_ai.evaluation.result&lt;/code&gt; (name, score, label, explanation). It has &lt;strong&gt;zero references in the SigNoz codebase&lt;/strong&gt;. We emit it: an LLM-as-judge scores an answer, and the score travels into SigNoz over OTLP as spec-shaped telemetry.&lt;/p&gt;

&lt;p&gt;Crucially, &lt;em&gt;how&lt;/em&gt; you carry it decides whether it's usable. We learned this the hard way and built a probe (&lt;code&gt;probe_eval_carriers.py&lt;/code&gt;) that emits the same score three ways side by side to prove which one survives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Span events&lt;/strong&gt; are stored in ClickHouse as &lt;code&gt;Array(String)&lt;/code&gt; — you &lt;strong&gt;cannot filter or aggregate inside them.&lt;/strong&gt; An eval you can't &lt;code&gt;GROUP BY&lt;/code&gt; is not auditable.&lt;/li&gt;
&lt;li&gt;A spec-compliant &lt;strong&gt;log record&lt;/strong&gt; and a dedicated &lt;strong&gt;evaluation span&lt;/strong&gt; both land as queryable attributes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we carry every score on the two that SigNoz can actually aggregate, never on a span event.&lt;/p&gt;

&lt;h3&gt;
  
  
  The judge earns its place by benchmark, not reputation
&lt;/h3&gt;

&lt;p&gt;The judge is the load-bearing piece: every downstream claim is only as trustworthy as the score it produces. So we picked it by measuring it against our actual task, and we picked it to run &lt;strong&gt;locally on open weights&lt;/strong&gt; — &lt;code&gt;gpt-oss:20b&lt;/code&gt; on Ollama, free and self-hostable.&lt;/p&gt;

&lt;p&gt;That last choice is architecture, not thrift. This entire project exists to &lt;em&gt;remove&lt;/em&gt; a gate between a self-hoster and a feature. A judge that needed a paid API key would reintroduce exactly that kind of gate — now you need a billing account before you can evaluate anything. A free, self-hostable judge works for the same person we're unblocking.&lt;/p&gt;

&lt;p&gt;We benchmarked five candidates. The results were not what "bigger is better" would predict:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;valid JSON&lt;/th&gt;
&lt;th&gt;grounded&lt;/th&gt;
&lt;th&gt;fabricated&lt;/th&gt;
&lt;th&gt;separation&lt;/th&gt;
&lt;th&gt;jitter (temp 0)&lt;/th&gt;
&lt;th&gt;p50 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;gpt-oss:20b&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.16s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss:120b&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.133&lt;/td&gt;
&lt;td&gt;+0.867&lt;/td&gt;
&lt;td&gt;0.250&lt;/td&gt;
&lt;td&gt;4.66s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nemotron-3-nano:30b&lt;/td&gt;
&lt;td&gt;flaky&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.270&lt;/td&gt;
&lt;td&gt;+0.730&lt;/td&gt;
&lt;td&gt;0.180&lt;/td&gt;
&lt;td&gt;5.38s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deepseek-v4-flash&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;HTTP 403 (not in tier)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma4:31b&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;returned non-JSON&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The bigger model was the noisier one.&lt;/strong&gt; On a repeated fabricated input at temperature 0, &lt;code&gt;gpt-oss:120b&lt;/code&gt; returned 0.25 / 0.00 / 0.15 — a 0.25 spread — while &lt;code&gt;gpt-oss:20b&lt;/code&gt; returned 0.00 every single time. A quality-regression alert lives or dies on that stability: a judge that wobbles ±0.25 makes "quality dropped 12%" indistinguishable from measurement noise, and can name the &lt;em&gt;wrong&lt;/em&gt; release as the culprit. We chose the model our alert can actually trust, and it happened to be the smaller, faster, free one.&lt;/p&gt;

&lt;p&gt;Every score also carries the two attributes that make it reproducible after the fact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.evaluation.judge.model&lt;/code&gt; — which model scored it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;judge.prompt.version&lt;/code&gt; — which rubric version scored it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "which judge and which rubric produced this score?" is a &lt;code&gt;GROUP BY&lt;/code&gt;, not a shrug.&lt;/p&gt;

&lt;h3&gt;
  
  
  The alert that names the release that regressed
&lt;/h3&gt;

&lt;p&gt;The payoff is a threshold alert: &lt;code&gt;avg(gen_ai.evaluation.score.value)&lt;/code&gt; below &lt;code&gt;0.7&lt;/code&gt;, over a 30-minute window, &lt;strong&gt;grouped by &lt;code&gt;service.version&lt;/code&gt;&lt;/strong&gt;. When a release's answer quality slides under the floor, the alert fires &lt;em&gt;and names the release&lt;/em&gt;. No anomaly detection — that's enterprise-gated in SigNoz — just a threshold on a signal we made aggregatable.&lt;/p&gt;

&lt;p&gt;We measured the full loop end to end against a live instance, not estimated it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;emit at &lt;code&gt;16:21:43Z&lt;/code&gt; → alert transitioned to &lt;strong&gt;firing&lt;/strong&gt; at &lt;code&gt;16:25:40Z&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;span-to-alert = 238s (~4 min)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's the part that matters for anyone building on this: that time is almost entirely SigNoz's own machinery — the 120s &lt;code&gt;eval_delay&lt;/code&gt; plus the 1-minute evaluation interval. The judge adds only ~2.5s per call, and because spans are timestamped when the run &lt;em&gt;starts&lt;/em&gt;, even that overlaps the &lt;code&gt;eval_delay&lt;/code&gt; rather than stacking on it. &lt;strong&gt;The eval layer adds essentially nothing to detection time; SigNoz's evaluation interval is the floor.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the demo we use a deliberately loud drop — measured mean &lt;strong&gt;0.958 → 0.167&lt;/strong&gt; — so the mechanism is legible in one glance. That's a demo choice, not a limitation: the alert fires on the same 0.7 floor whether the slide is a cliff or a subtle crossing.&lt;/p&gt;




&lt;h2&gt;
  
  
  How we deployed it — and made it reproducible
&lt;/h2&gt;

&lt;p&gt;We ran SigNoz self-hosted in WSL2 (Ubuntu 24.04) on native Docker Engine — &lt;em&gt;not&lt;/em&gt; Docker Desktop, whose ClickHouse Keeper is known to segfault. Deployment is via &lt;strong&gt;SigNoz Foundry&lt;/strong&gt; (&lt;code&gt;foundryctl&lt;/code&gt;), their "one config, one command" tool: you declare the whole deployment in a &lt;code&gt;casting.yaml&lt;/code&gt;, &lt;code&gt;forge&lt;/code&gt; expands it into platform manifests plus a checksum-pinned &lt;code&gt;casting.yaml.lock&lt;/code&gt;, and &lt;code&gt;cast&lt;/code&gt; deploys it.&lt;/p&gt;

&lt;p&gt;Because the judges of this hackathon may re-run Foundry to reproduce our deployment, we didn't hand-edit generated files — we put the fix &lt;em&gt;in the source of truth&lt;/em&gt;. Our &lt;code&gt;casting.yaml&lt;/code&gt; sets both gates as first-class env overrides on the SigNoz service:&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;flavor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compose&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
  &lt;span class="na"&gt;signoz&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v0.133.0&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;signoz/signoz:v0.133.0&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;SIGNOZ_FLAGGER_CONFIG_BOOLEAN_ENABLE__AI__OBSERVABILITY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
        &lt;span class="na"&gt;SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__DASHBOARD__V2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We verified this is the right way to do it: &lt;code&gt;forge&lt;/code&gt; &lt;em&gt;merges&lt;/em&gt; those overrides into the defaults (it doesn't wipe the DB/store env), the flags render into the generated &lt;code&gt;compose.yaml&lt;/code&gt;, and — critically — &lt;strong&gt;forge is deterministic&lt;/strong&gt;: re-running it produces a byte-identical lock. So a judge who clones the repo and runs &lt;code&gt;foundryctl forge &amp;amp;&amp;amp; foundryctl cast&lt;/code&gt; gets the &lt;em&gt;feature-reachable&lt;/em&gt; deployment, not the default empty shell. The casting file is part of the argument, not a checkbox.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we'd tell the next person
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treat a 200 as the beginning of verification, not the end.&lt;/strong&gt; Both gates that cost us the most time were backends succeeding while frontends hid the result. Visually confirm every dashboard; never trust the POST.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick your LLM judge by measuring it, not by its size or name.&lt;/strong&gt; The stability of the judge is the stability of your alert. Bigger lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose carriers your backend can aggregate.&lt;/strong&gt; A beautiful eval signal stuffed into a span event is invisible to &lt;code&gt;GROUP BY&lt;/code&gt;. Know your storage model before you emit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the fix in the source of truth, not the generated output&lt;/strong&gt; — so reproduction reproduces the &lt;em&gt;fixed&lt;/em&gt; thing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SigNoz built a genuinely good LLM-observability product. We made it reachable, filed the bug that was blocking cost, and added the measured-quality signal it was missing — all in OSS, all reproducible from one &lt;code&gt;casting.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Repo (with &lt;code&gt;casting.yaml&lt;/code&gt; + &lt;code&gt;casting.yaml.lock&lt;/code&gt; for reproduction): &lt;a href="https://github.com/gajanand27-05/SigNoz-conform" rel="noopener noreferrer"&gt;https://github.com/gajanand27-05/SigNoz-conform&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>llm</category>
      <category>hackathon</category>
    </item>
  </channel>
</rss>
