<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Sampreeth Avvari</title><description>Writing on agentic AI, RAG, and ML systems.</description><link>https://sampreethavvari.github.io/</link><item><title>How I saved our accountant 400 hours a year</title><link>https://sampreethavvari.github.io/posts/accounting-automation/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/accounting-automation/</guid><description>Every week our accountant hand-copied numbers from five different systems. A dozen small Python scripts do it for him now, built the way he actually works.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Garrett is the controller at Hybridge. Every Monday morning he’d download CSVs from five different systems, open each one, rename columns, copy-paste rows, hand-tag deposit accounts, look up vendor names by check number. Then do it again for the next file. An entire workday a week was disappearing into reformatting work that produced no decisions, only inputs.&lt;/p&gt;
&lt;p&gt;The cost isn’t just his time. Garrett is the person the operations team needs when something looks wrong in the numbers. While he was buried in paste-and-rename, everyone else was waiting. A small dental-implant clinic can’t afford to have its only senior accountant running a manual ETL pipeline every week.&lt;/p&gt;
&lt;p&gt;I’m the sole AI engineer at Hybridge. When I sat down with Garrett and watched the Monday workflow, the problem wasn’t that nobody had tried to fix it. The problem was that the obvious fix, one unified import platform, would have broken every time a vendor changed a column header, which they do constantly. What Garrett actually needed was a dozen small scripts: one per upstream system, short enough to read in ten minutes, and editable without my help.&lt;/p&gt;
&lt;h2&gt;The five upstream systems&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;MagicTouch (DLCPM)  →  payments + production&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Denticon            →  Buffalo location&apos;s PMS&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Paychex             →  payroll, time-off exports&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Bank (Buffalo)      →  AccountTransactionDetail CSV&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Bank (Hybridge)     →  AccountTransactionDetail CSV&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;None of them speak each other’s format. None of them export the columns the downstream sheets need. The bank CSV has a two-line preamble and a two-line trailer. Paychex encodes company name into the filename and department codes need a lookup table. Denticon and MagicTouch label the same payment type three different ways.&lt;/p&gt;
&lt;h2&gt;What I built instead&lt;/h2&gt;
&lt;p&gt;A dozen small Python scripts. Each one is the verb for one upstream system. Each one is short enough to read in ten minutes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;AR_Aging.py                             receivables aging, multi-sheet Excel&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Buffalo_Ramp_IIF_Reformat.py            IIF for QuickBooks&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;cashflow_import.py                      bank CSV → master cashflow Sheet&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;checks_import.py                        outstanding checks ledger&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Denticon_Payment_Import_Buffalo.py      Denticon payments&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Denticon_Production_Import_Buffalo.py   Denticon production&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;MT_Payments_Import.py                   MagicTouch payments&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;MT_Sales_Import.py                      MagicTouch sales&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Paychex_Time_Off_Information_Cleanup.py payroll time-off&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;+ three reference Excel files&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Garrett never runs a generic ETL command. He runs &lt;code&gt;python MT_Payments_Import.py&lt;/code&gt; after he downloads the MagicTouch export, and a clean IIF appears in his Downloads folder. The mental model matches the calendar reality.&lt;/p&gt;
&lt;h2&gt;Configuration that respects how the user actually works&lt;/h2&gt;
&lt;p&gt;Each script has the same shape: imports, then a config block, then the functions. The config sits at the top so Garrett can edit it himself without asking me.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;DEPOSIT_ACCOUNTS&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;credit card&quot;&lt;/span&gt;&lt;span&gt;:     &lt;/span&gt;&lt;span&gt;&quot;Cardpointe Clearing&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;check&quot;&lt;/span&gt;&lt;span&gt;:           &lt;/span&gt;&lt;span&gt;&quot;ACH Check Clearing&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;echeck&quot;&lt;/span&gt;&lt;span&gt;:          &lt;/span&gt;&lt;span&gt;&quot;ACH Check Clearing&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;ach&quot;&lt;/span&gt;&lt;span&gt;:             &lt;/span&gt;&lt;span&gt;&quot;ACH Check Clearing&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;direct deposit&quot;&lt;/span&gt;&lt;span&gt;:  &lt;/span&gt;&lt;span&gt;&quot;ACH Check Clearing&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;echecks/ach&quot;&lt;/span&gt;&lt;span&gt;:     &lt;/span&gt;&lt;span&gt;&quot;ACH Check Clearing&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;direct deposits&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;ACH Check Clearing&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When MagicTouch invents a new deposit type label, Garrett opens the file and adds a line. No database migration. No deploy. No engineer.&lt;/p&gt;
&lt;h2&gt;The interactive y/s/d prompt, because automation shouldn’t be silent&lt;/h2&gt;
&lt;p&gt;I’d originally written the bank import as a one-pass loop that processes every matching file. Garrett pushed back. Half the time the glob picks up old downloads, partial files, test files from a vendor call. He wants to see each one and decide.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; f &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; files:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    print&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;\n&lt;/span&gt;&lt;span&gt;File detected:&lt;/span&gt;&lt;span&gt;\n{&lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    choice &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; input&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Process this file? (y = yes, s = skip, d = delete): &quot;&lt;/span&gt;&lt;span&gt;).strip()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; choice &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &apos;y&apos;&lt;/span&gt;&lt;span&gt;: approved.append(f)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    elif&lt;/span&gt;&lt;span&gt; choice &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &apos;s&apos;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;pass&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    elif&lt;/span&gt;&lt;span&gt; choice &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &apos;d&apos;&lt;/span&gt;&lt;span&gt;: os.remove(f)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the kind of detail you only know if you watch the work get done. The script &lt;em&gt;could&lt;/em&gt; be silent. The accountant doesn’t &lt;em&gt;want&lt;/em&gt; silent. Trust grows by showing each step.&lt;/p&gt;
&lt;h2&gt;Retry with backoff, because Sheets API throttles&lt;/h2&gt;
&lt;p&gt;Long imports hit &lt;code&gt;APIError: Quota exceeded&lt;/code&gt; two or three times mid-run. The decorator that wraps every Sheets call:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; retry_with_backoff&lt;/span&gt;&lt;span&gt;(retries&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;, backoff_in_seconds&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    def&lt;/span&gt;&lt;span&gt; decorator&lt;/span&gt;&lt;span&gt;(func):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        def&lt;/span&gt;&lt;span&gt; wrapper&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;args, &lt;/span&gt;&lt;span&gt;**&lt;/span&gt;&lt;span&gt;kwargs):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            for&lt;/span&gt;&lt;span&gt; x &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; range&lt;/span&gt;&lt;span&gt;(retries &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; 1&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                try&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; func(&lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;args, &lt;/span&gt;&lt;span&gt;**&lt;/span&gt;&lt;span&gt;kwargs)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                except&lt;/span&gt;&lt;span&gt; APIError &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; e:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    if&lt;/span&gt;&lt;span&gt; x &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; retries: &lt;/span&gt;&lt;span&gt;raise&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                    time.sleep(backoff_in_seconds &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt; **&lt;/span&gt;&lt;span&gt; x))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        return&lt;/span&gt;&lt;span&gt; wrapper&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; decorator&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Garrett never sees the throttle. He sees a one-line “retrying in 4s” message and the run continues.&lt;/p&gt;
&lt;h2&gt;By the numbers&lt;/h2&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;~400 hrs/yr&lt;/div&gt;
    &lt;div&gt;of senior accounting time saved&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;6-8 h → 30 min&lt;/div&gt;
    &lt;div&gt;weekly reformat workload&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;12&lt;/div&gt;
    &lt;div&gt;small Python scripts, one per system&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Before&lt;/th&gt;&lt;th&gt;After&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Weekly accounting reformat workload&lt;/td&gt;&lt;td&gt;~6-8 hours&lt;/td&gt;&lt;td&gt;~30-45 minutes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Of which is actual work&lt;/td&gt;&lt;td&gt;All of it&lt;/td&gt;&lt;td&gt;Downloading files + answering y/s/d&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Reformat errors per quarter&lt;/td&gt;&lt;td&gt;Occasional silent paste-wrong-column&lt;/td&gt;&lt;td&gt;Near zero (deterministic output)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Lines of code per script&lt;/td&gt;&lt;td&gt;80-500&lt;/td&gt;&lt;td&gt;Short enough to read in 10 min&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The pattern here matters more than the code. &lt;strong&gt;Most internal automation projects fail not because the engineering is bad but because the engineering builds a platform when the user wanted a tool.&lt;/strong&gt; A tool is a thing you keep in a drawer. A platform is a thing you log into. Tools are easier to trust.&lt;/p&gt;
&lt;p&gt;Garrett doesn’t run a server. He runs scripts. Six months in, he has already edited two config blocks himself when a vendor renamed a column. He didn’t need to file a ticket or wait for me. That’s the deliverable I was aiming for: not automation that requires an engineer to maintain it, but automation that belongs to the person using it.&lt;/p&gt;</content:encoded></item><item><title>How I replaced a $98K quote with a $50-a-month tool</title><link>https://sampreethavvari.github.io/posts/cbct-scan-validator/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/cbct-scan-validator/</guid><description>A vendor wanted $98K up front and $26K a year to flag bad dental CT scans. I built it in-house on Cloud Run for under $50 a month, with one painful lesson.</description><pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;About 10-15% of the 600 CBCT scans that flow through our design team each month are unusable: motion blur, metal streaks, a missing jaw region, a calibration ring of death. A designer doesn’t catch it for twenty minutes. The case gets escalated. The patient gets a rescan appointment two days later.&lt;/p&gt;
&lt;p&gt;That loop had been running for ten years.&lt;/p&gt;
&lt;p&gt;A vendor offered to break it: &lt;strong&gt;$98K up front + $26K a year&lt;/strong&gt;. Binary good/bad classifier, no MLOps, they keep the IP. Hybridge would have been paying $26K a year indefinitely to flag scans it never owned the logic for.&lt;/p&gt;
&lt;p&gt;So I built it instead, as sole AI engineer at Hybridge, on a laptop GPU and a CPU-only Cloud Run service.&lt;/p&gt;
&lt;h2&gt;The cost picture, before anything else&lt;/h2&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;$124K&lt;/div&gt;
    &lt;div&gt;vendor year-one cost&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;&amp;lt; $600&lt;/div&gt;
    &lt;div&gt;year-one cloud spend, in-house&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;100%&lt;/div&gt;
    &lt;div&gt;IP retained: weights, code, MLOps stack&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Vendor proposal&lt;/th&gt;&lt;th&gt;What I built&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Year 1&lt;/td&gt;&lt;td&gt;~$98K + $26K = &lt;strong&gt;$124K&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&amp;lt; $600 cloud spend&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Year 2 onward&lt;/td&gt;&lt;td&gt;$26K / yr&lt;/td&gt;&lt;td&gt;&amp;lt; $600 / yr&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;IP&lt;/td&gt;&lt;td&gt;Vendor keeps it&lt;/td&gt;&lt;td&gt;100% ours&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Scope&lt;/td&gt;&lt;td&gt;Binary classifier&lt;/td&gt;&lt;td&gt;13-class taxonomy + 4-family routing + active learning + full MLOps&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Hardware&lt;/td&gt;&lt;td&gt;TBD&lt;/td&gt;&lt;td&gt;None bought, RTX 4080 laptop existed&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;The model picked itself&lt;/h2&gt;
&lt;p&gt;When the project started I had 17 to 23 labeled studies. At that data size, picking a single architecture is a coin flip. So I built six in parallel behind the same interface:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;A   ConvNeXt-Tiny + gated-attention MIL&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;B   ConvNeXt + 4-layer Transformer over slices&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;C   MONAI DenseNet-3D&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;D   nnU-Net adapter&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;E   3D VAE (anomaly)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;F   ST-MAE (reconstruction anomaly)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let the data tell me which one held up. Model F won round one. Then Model H, an architecture using a frozen DentalSegmentator nnU-Net encoder with a small multi-scale head, won the production slot once I had more labeled bads to evaluate against.&lt;/p&gt;
&lt;p&gt;The whole stack in one diagram:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;CBCT volume (1, 64, 224, 224)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ▼&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Frozen DentalSegmentator encoder (30.79M params, no gradients)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├─ deep stage  → global pool      (320 dims)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├─ mid stage   → global pool      (256 dims)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    └─ early stage → BiGRU + attention (512 dims)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ▼&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Concat (1088 dims) → small head (500K trainable params)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ├─ Binary head (good / bad)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    └─ Family head (clean / patient / xray / scanner)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;13-class assignment is done by a deterministic router downstream, because eight of the thirteen classes have exactly one training sample. Don’t try to learn what you can compute.&lt;/p&gt;
&lt;h2&gt;The painful lesson&lt;/h2&gt;
&lt;p&gt;After several weeks of tuning, my test AUROC came in at &lt;strong&gt;0.80&lt;/strong&gt;. Beautiful number. I drafted the announcement.&lt;/p&gt;
&lt;p&gt;Then I went back and checked the splits one more time. Seven of the ten test bads had also appeared in earlier training during an experiment I’d run. They’d leaked. After redoing the split cleanly, the honest test AUROC was &lt;strong&gt;0.6309&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I shipped 0.6309.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;&amp;lt;$50/mo&lt;/div&gt;
  &lt;div&gt;Cloud spend vs the $124K year-one vendor quote, same ten-year operating loss closed in-house&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Shipping 0.80 would have promised something the model couldn’t deliver. The next person to run a proper holdout would have caught it. Better to be honest about where the model stands and lean on the active-learning queue for borderline cases.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A 99%-accurate model with no lineage and no honest holdout is operationally worse than a 75%-accurate model with both.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Cost-tuned threshold, not accuracy-tuned&lt;/h2&gt;
&lt;p&gt;A missed bad scan costs 24-48 hours of delay plus a rescan appointment. A false positive costs maybe 30 seconds of human review. So the threshold isn’t tuned to maximise accuracy. It’s tuned to minimise this cost:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;cost = 15 × FN_count + 1 × FP_count&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That gives an operating threshold of &lt;strong&gt;0.176&lt;/strong&gt; on the binary score. Anything within ±0.10 of that lands in a human-review queue, which feeds the next retrain.&lt;/p&gt;
&lt;h2&gt;Production deployment in one paragraph&lt;/h2&gt;
&lt;p&gt;Cases flow in from MagicTouch DLCPM over SFTP (no API license needed). Eventarc fires on the GCS upload. Cloud Run pulls the encoder (OpenVINO IR, immutable per release) and the head checkpoint (hot-swappable via GCS, no rebuild needed for retrain). End-to-end latency including PDF report generation: about &lt;strong&gt;5.5 seconds&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;The numbers that matter&lt;/h2&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Before&lt;/th&gt;&lt;th&gt;After&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Vendor cost over year 1&lt;/td&gt;&lt;td&gt;$124K + 22 hrs/mo of internal time&lt;/td&gt;&lt;td&gt;$0 external, &amp;lt;$50/mo cloud&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;End-to-end latency&lt;/td&gt;&lt;td&gt;n/a&lt;/td&gt;&lt;td&gt;~5.5 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;60-90 bad scans/month reaching design&lt;/td&gt;&lt;td&gt;24-48 hours wasted each&lt;/td&gt;&lt;td&gt;Caught at intake&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Wasted design-team hours / month&lt;/td&gt;&lt;td&gt;60-120&lt;/td&gt;&lt;td&gt;Near zero&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Patient case turnaround on affected cases&lt;/td&gt;&lt;td&gt;+24-48 hrs&lt;/td&gt;&lt;td&gt;Same-day&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;I ran six model architectures, picked the frozen-encoder design because the data supported it, and shipped 0.6309 when 0.80 would have looked nicer on a slide. A model on its own is a science fair. A model with SFTP fan-out, Eventarc, CICT gates, a tag-based release pipeline, a hot-swap retrain loop, and a dashboard somebody actually opens: that’s the thing that closes a ten-year operating loss. The number that matters isn’t the peak AUROC from a leaky split. It’s the one you can stand behind when the next holdout runs.&lt;/p&gt;</content:encoded></item><item><title>Teaching an AI to read the whole patient and lean toward a treatment, before the doctor does</title><link>https://sampreethavvari.github.io/posts/cdf-diagnostic-filter/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/cdf-diagnostic-filter/</guid><description>The Centralized Diagnostic Filter is HYBRIDGE&apos;s diagnostic operating system. AI reads every input a complex case generates (CBCT, intraoral scans, clinical photos, radiographs, a risk survey), surfaces the findings, scores future tooth-loss risk, and leans toward a treatment direction, all before the consult. Then the doctor validates every finding. This is the forward-deployed problem at its hardest: turning a veteran clinician&apos;s mental model into a buildable, phased system. Phase 1 is in build.</description><pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most of my projects start from a broken workflow. This one started from a clinician’s head, and it is the only one I have built where the point is to have an AI read the patient before the doctor walks in.&lt;/p&gt;
&lt;p&gt;Dr. Frank LaMar, who founded the practice, has a way of reading a complex restorative case. He looks at the bone, the wear, the decay pattern, the patient’s own words about pain and confidence, and he forms a judgment about whether the natural teeth are a dependable long-term bet or whether the honest conversation is about replacement. The Centralized Diagnostic Filter, CDF, is the attempt to put that first read into software: take every input a case generates, let the AI surface the findings and lean toward a treatment direction, and hand the doctor a finished assessment to confirm or correct, before the consultation even begins.&lt;/p&gt;
&lt;p&gt;That last part is the whole philosophy. The CDF is not AI that replaces the dentist, and it is not AI that just reads x-rays. It is a standardized restorative prognosis operating system, and its governing rule is simple: &lt;strong&gt;AI assists, doctors validate.&lt;/strong&gt; The AI does the first pass. Every finding it produces is provisional until a doctor signs it.&lt;/p&gt;
&lt;p&gt;This was the hardest requirements job I have done. The other systems replaced a spreadsheet or a brittle script. This one had to extract a thirty-year mental model from the person who has it, and shape it into something a computer can carry without flattening the clinical nuance that makes it useful.&lt;/p&gt;
&lt;h2&gt;What “reading the patient” actually means&lt;/h2&gt;
&lt;p&gt;A complex case is not one image. It is a pile of them, plus everything the patient says. The CDF pulls fourteen input classes into one place and analyses each for something specific:&lt;/p&gt;
&lt;div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CBCT scan&lt;/strong&gt;, and a panoramic view derived from that same scan, not a separate one: 3D bone, ridge architecture, available bone for implants.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Intraoral scans&lt;/strong&gt; from the TRIOS scanner: missing teeth, wear, occlusion, supraeruption, crowding, collapse.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clinical photo series&lt;/strong&gt; (full-face smile, retracted frontal, occlusal, buccal, profile): aesthetics, recession, support loss, visible deterioration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bitewings and periapicals&lt;/strong&gt;: decay, recurrent decay, restorations, root canals, structural breakdown.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Risk survey&lt;/strong&gt;: pain, embarrassment, function, urgency, dry mouth, smoking, grinding, medical risk, and treatment goals.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;The AI’s job is to look across all of that and surface what a doctor would otherwise reconstruct by hand: where the decay is, which teeth are gone, what is restored, how much bone support is left, and how the upper and lower jaws are each holding up.&lt;/p&gt;
&lt;h2&gt;The output the doctor is meant to confirm&lt;/h2&gt;
&lt;p&gt;The CDF walks into the consult having already done the reading. For every case it produces the same artifact: findings surfaced, risk scored, prognosis classified for each jaw, and a single treatment-direction leaning chosen from a fixed ladder.&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Leaning&lt;/th&gt;&lt;th&gt;What it says&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Conventional Viable&lt;/td&gt;&lt;td&gt;The natural teeth are a dependable long-term bet&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Conventional Compromised&lt;/td&gt;&lt;td&gt;Restoration is possible, but the risk factors are real&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Localized Implant Enhancement&lt;/td&gt;&lt;td&gt;A segment needs implants, the rest can stay&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Full Arch Consider&lt;/td&gt;&lt;td&gt;Replacement is worth the honest conversation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Full Arch Likely&lt;/td&gt;&lt;td&gt;Replacement is the more predictable path&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Full Arch Highly Appropriate&lt;/td&gt;&lt;td&gt;Replacement is clearly the better long-term bet&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;It never states a final diagnosis. It proposes a direction, shows the evidence behind it, and leaves the call to the doctor. That is the difference between a treatment leaning and a treatment decision, and the whole system is built to respect it.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;the doctor always confirms&lt;/div&gt;
  &lt;div&gt;The report suggests a leaning and surfaces the evidence for it. It never delivers a final diagnosis. That boundary is clinical and legal, and it is wired into the architecture, not left to discipline.&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;Phase 1: own the front door first&lt;/h2&gt;
&lt;p&gt;You do not build the operating system on day one. You build the thing the rest of it plugs into, and you ship it.&lt;/p&gt;
&lt;p&gt;Today the patient assessment lives in a third-party intake platform. The HIPAA posture is unverified, one person is the single point of edit, output gets screenshotted by hand into OneNote, each patient’s Drive folder is populated manually, and there are three duplicate questionnaires for the three markets (Rochester, Buffalo, Syracuse). It works, but it is fragile and it is not ours.&lt;/p&gt;
&lt;p&gt;Phase 1 replaces all of that with a HYBRIDGE-owned intake on Google Cloud: one questionnaire with location-aware routing for all three markets, a premium patient-facing PDF delivered the moment the patient submits, automatic filing into the patient’s Drive folder with no human in the loop, and self-serve admin so the practice’s intake lead can edit questions, manage recipients per office, and add team members without filing a ticket. BAA-covered, audit-logged, role-based from the first commit.&lt;/p&gt;
&lt;p&gt;It reads less like a dental form and more like an Apple product. That is deliberate. A premium intake experience is the first thing a patient touches, and it is also the data plane the AI reads from later, so it has to be owned and structured from day one.&lt;/p&gt;
&lt;h2&gt;The risk model, kept intact on purpose&lt;/h2&gt;
&lt;p&gt;The clinical core of the intake is a future-tooth-loss risk score, and one of the firmest decisions was to preserve the model the practice already trusts rather than invent a new one.&lt;/p&gt;
&lt;p&gt;It is twelve weighted questions. Smoking, uncontrolled diabetes, dry mouth, and a history of periodontal disease carry the heaviest weights; missing a full arch of teeth, family denture history, grinding and clenching, and self-rated hygiene fill in the rest. The weights sum to a 0 to 200 score across four bands:&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Band&lt;/th&gt;&lt;th&gt;Score&lt;/th&gt;&lt;th&gt;What it frames&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Low risk&lt;/td&gt;&lt;td&gt;0-25&lt;/td&gt;&lt;td&gt;Restoration is a viable long-term option&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Above average&lt;/td&gt;&lt;td&gt;30-70&lt;/td&gt;&lt;td&gt;Restoration is possible, but other factors matter&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;High risk&lt;/td&gt;&lt;td&gt;75-100&lt;/td&gt;&lt;td&gt;Replacement may be the more predictable path&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Very high risk&lt;/td&gt;&lt;td&gt;110-200&lt;/td&gt;&lt;td&gt;Replacement is likely the better long-term bet&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The score is not a diagnosis. It is a way to frame the restore-versus-replace conversation in a consistent, defensible way. Keeping the existing scale and bands means a patient who took the old questionnaire and the new one will not see a confusing numeric shift. Same clinical meaning, better instrument.&lt;/p&gt;
&lt;h2&gt;The full report, in nine sections&lt;/h2&gt;
&lt;p&gt;Phase 2 is the report Dr. Frank actually sketched: every patient produces the same artifact set, and every doctor reads the same document. Those fourteen inputs land in one visual report organized into nine sections.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Executive Summary&lt;/strong&gt; answers four questions in plain language: what is happening, how severe, the long-term outlook, and the most reasonable treatment direction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Risk Scoring&lt;/strong&gt; carries the 0 to 200 score and a separate read on long-term predictability.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Anatomical Mapping&lt;/strong&gt; locates the sinuses, mental foramina, ridge architecture, available bone, and implant zones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bone Support Visualization&lt;/strong&gt; is the centerpiece (more on it below).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structural Analysis&lt;/strong&gt; outlines missing teeth, restorations, bridgework, implants, root canals, retained roots, and structural collapse.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Functional Analysis&lt;/strong&gt; grades occlusal wear, guidance, posterior support, parafunction, and occlusal disease from none to severe.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prognosis&lt;/strong&gt; classifies the maxilla and mandible separately, because an upper jaw can be poor while the lower is fair, and that distinction changes the plan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Patient Impact&lt;/strong&gt; surfaces the emotional and functional drivers from the survey: this is the “why now” that a consultation lives or dies on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treatment Leaning&lt;/strong&gt; picks a direction off the ladder above without ever stating a final diagnosis. The doctor always confirms.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The centerpiece: bone support, current versus ideal&lt;/h2&gt;
&lt;p&gt;The innovation at the heart of the CDF is not the AI. It is a way of showing bone.&lt;/p&gt;
&lt;p&gt;Standardized Bone Support Visualization Mapping renders a patient’s current bone architecture against an ideal reference, so the support discrepancy and ridge atrophy are visible at a glance. A patient who would glaze over at a periodontal chart understands a picture of where their bone is versus where it should be. It also standardizes how doctors read periodontal health and frame long-term predictability. One concept, doing real work for both audiences.&lt;/p&gt;
&lt;p&gt;There is a clinical insight underneath it that the system has to respect: not every heavily restored mouth is failing. The design distinguishes a generalized terminal dentition from a stable restorative adaptation, evaluating bone support, decay pattern, maintainability, biological risk, functional stability, and restorative burden rather than just counting fillings. That distinction came straight out of early testing, and getting it wrong would make the system confidently misleading.&lt;/p&gt;
&lt;h2&gt;The AI layer, and where it is allowed to act&lt;/h2&gt;
&lt;p&gt;Phase 3 is the part that makes the heading literal: the AI does the reading. Decay, missing teeth, restorations, and root-canal identification on 2D radiographs, plus the bone-level mapping that feeds the visualization. It assembles all of that into the draft assessment before anyone sits down with the patient.&lt;/p&gt;
&lt;p&gt;But every AI output lands in a doctor validation queue and stays provisional until a doctor signs it. Those corrections are not discarded. They feed a continuous-learning loop on HYBRIDGE’s own validated cases, so every signed case strengthens the next prediction. The AI reads first, the doctor decides, and the doctor’s decisions are what teach the AI. The data compounds, and it stays ours.&lt;/p&gt;
&lt;p&gt;That ownership is a principle, not an afterthought. Data, code, brand, and integrations live under HYBRIDGE accounts with no vendor lock-in, the schema is versioned and auditable from day one so Phase 4 plugs in without re-architecture, and Phase 4 itself is the integration layer: write-back into practice-management software so nobody re-keys data, outcome tracking that validates predictability against real results, multi-office rollout, and a pre-consult patient portal.&lt;/p&gt;
&lt;h2&gt;The roadmap, with honest status&lt;/h2&gt;
&lt;p&gt;Each phase ships independently and delivers visible value without waiting on the ones after it.&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Scope&lt;/th&gt;&lt;th&gt;Status&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;1: Questionnaire system&lt;/td&gt;&lt;td&gt;HYBRIDGE-owned premium intake, PDF, auto-distribution, self-serve admin&lt;/td&gt;&lt;td&gt;In build&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2: Diagnostic report (CDF v1)&lt;/td&gt;&lt;td&gt;One report from fourteen inputs, consult dashboard, risk + treatment lean&lt;/td&gt;&lt;td&gt;Designed, queued&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3: AI-assisted findings&lt;/td&gt;&lt;td&gt;Radiograph assist, bone-support visualization, doctor validation queue&lt;/td&gt;&lt;td&gt;Designed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;4: Scale and integrations&lt;/td&gt;&lt;td&gt;PMS write-back, outcome tracking, multi-office, patient portal&lt;/td&gt;&lt;td&gt;Planned&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;What is actually in place today, with no rounding up: the spec is written and the architecture approved, with a 19-task implementation plan and three planning sessions done with the practice’s intake lead. The private repository is live under the HYBRIDGE org. The Google Cloud foundation is set up with a dedicated billing account, isolated dev and prod projects, Terraform-managed infrastructure, and HIPAA-eligible services only. The brand system is applied, six cover concepts are designed for the patient PDF, and the risk model is preserved at its existing scale and bands. Underway right now: the questionnaire schema (thirty-plus questions with conditional logic, fully tested), the patient form interface, the PDF report generator, and the email-and-Drive delivery pipeline.&lt;/p&gt;
&lt;h2&gt;Why this one is the interesting problem&lt;/h2&gt;
&lt;p&gt;The engineering here is real, but it is not the hard part. The hard part is the elicitation: sitting with a clinician, pulling the structure out of how he actually reasons, and deciding what a system should standardize versus what it must leave to the doctor. Get that boundary wrong in either direction and you have either a glorified form or a liability. Phasing it is how you de-risk it: own the front door first, prove the intake, then build the report, then let the AI read but never decide. The goal was never to automate the diagnosis. It was to have the AI do the first read so the doctor walks in already holding a consistent, legible, evidence-backed assessment, and still holds the pen.&lt;/p&gt;</content:encoded></item><item><title>How I built an AI that grades every patient consult</title><link>https://sampreethavvari.github.io/posts/clinical-rag/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/clinical-rag/</guid><description>A Zoom call comes in. My tool finds the doctor, grades the consult against the CEO&apos;s 7-point checklist, and emails back a color-coded report, without making things up.</description><pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every week, implant consultations finished and nothing happened to them. The doctor moved on. The treatment coordinator moved on. Our CEO, Dr. Frank LaMar, had a seven-criterion framework for what a good consult looks like. It lived in a Word doc. Nobody else could apply it at the speed the practice runs.&lt;/p&gt;
&lt;p&gt;So the coaching loop closed on maybe a handful of cases a week: the ones Frank had time to pull up himself. The rest were invisible.&lt;/p&gt;
&lt;p&gt;I was the only engineer on this. I had Zoom webhooks, Vertex AI, and a Google Workspace with no Super Admin access. I built the pipeline that reads every transcript, scores it against Frank’s rubric, and gets a color-coded report to the doctor, the TC, and Frank before the patient’s follow-up window closes.&lt;/p&gt;
&lt;h2&gt;The flow, end to end&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;Zoom consultation finishes&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ▼  recording.transcript_completed webhook&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Cloud Run (FastAPI)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ filter: is this actually a consult?&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ resolve identity: who&apos;s the doctor, the patient, the TC?&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ score: Vertex AI Gemini, schema-validated, retry once on fail&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ render: color-coded HTML + PDF report&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ archive: Drive folder by location / month / patient&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ send: Gmail to doctor + CEO + TC&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ append: one row to a master Google Sheet (48 columns, locked)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        └─ audit: BigQuery row with no PHI&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Total time from transcript-ready to email in inbox: 5-15 minutes.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Three constraints I had to design around&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;HIPAA.&lt;/strong&gt; Every transcript is PHI. Everything stays inside Google’s BAA boundary. No PHI in any log line, ever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No Workspace Super Admin.&lt;/strong&gt; I’m not the org admin. Domain-Wide Delegation (the standard way to give a service account Gmail/Drive access on behalf of users) was off the table. I used a user-OAuth grant on my own account instead, with the refresh token in Secret Manager. The Workspace sees a user account doing user-account-shaped things, which it already trusts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Doctor-TC pairing is fully flexible.&lt;/strong&gt; Any doctor can pair with any TC. There’s no default. The system has to figure out the doctor per meeting.&lt;/p&gt;
&lt;h2&gt;Doctor identity, three layers deep&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;1.  Zoom participants    →  email match → display name → alias match&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;2.  Transcript speakers  →  &quot;Dr Mike:&quot; speaker labels + alias match&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;3.  Gemini extraction    →  scorecard&apos;s doctor_name_candidates&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;4.  Unresolved           →  send to CEO only, prefix subject&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                            [QA: DOCTOR UNRESOLVED]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each layer returns a unique match, an ambiguous match, or no match. Ambiguity falls through. Unresolved cases don’t get silently dropped. They get flagged.&lt;/p&gt;
&lt;p&gt;One detail I learned the hard way: short single-word aliases like “Frank” and “Mike” need word-boundary matching. Otherwise they fire inside patient names and casual speech all the time. Multi-word aliases like “Dr. Mike Baleno” are fine on substring.&lt;/p&gt;
&lt;h2&gt;The contract that killed half the hallucinations&lt;/h2&gt;
&lt;p&gt;Free-form LLM output is a bad pattern for anything that has to feed a database. The scorer doesn’t ask Gemini for a report. It asks for a JSON object that passes a JSON Schema.&lt;/p&gt;
&lt;p&gt;If validation fails, the scorer retries exactly once with a hardened system prompt: &lt;em&gt;“Your previous response failed schema validation. Output ONLY JSON. No prose. No markdown fences.”&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;If the retry also fails, it raises, alerts, and stops.&lt;/p&gt;
&lt;p&gt;That single change cut &lt;strong&gt;hallucinations by ~35%&lt;/strong&gt; versus the no-schema baseline. The model is the same. The difference is the shape it has to produce.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;+130%&lt;/div&gt;
  &lt;div&gt;Treatment acceptance after every consult started getting coached, not just the few Frank had time for&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;Color coding against a rolling baseline&lt;/h2&gt;
&lt;p&gt;Each criterion gets a 1-10 score. The HTML email colors each one against the doctor’s &lt;strong&gt;own 30-day rolling average&lt;/strong&gt; for that criterion, not a global baseline. That’s the only way the report makes sense when a new TC pairs with a doctor for the first time.&lt;/p&gt;
&lt;p&gt;Rolling averages are read from the master Sheet every run, so the system never drifts from the ledger.&lt;/p&gt;
&lt;h2&gt;What it produced&lt;/h2&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Before&lt;/th&gt;&lt;th&gt;After&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Consultations scored&lt;/td&gt;&lt;td&gt;Frank’s hand-picked subset&lt;/td&gt;&lt;td&gt;Every implant consult, both Zoom orgs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Time from transcript to coaching report&lt;/td&gt;&lt;td&gt;Days, sometimes weeks&lt;/td&gt;&lt;td&gt;5-15 minutes&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The number I care most about from an engineering standpoint is the hallucination cut. A schema and one structured retry turn the same model into a reliable producer of clean rows. The acceptance lift and revenue growth are downstream of what the coaching loop did with those rows.&lt;/p&gt;
&lt;h2&gt;Why this matters beyond the metrics&lt;/h2&gt;
&lt;p&gt;Each report quotes the doctor’s own words back to them, criterion by criterion, with at least one timestamped moment showing where the conversation landed and where it didn’t. The coaching is grounded in something the doctor already remembers, not an abstract principle.&lt;/p&gt;
&lt;p&gt;That is what this project actually required: sitting with the clinicians and the TC long enough to understand why Frank’s Word doc worked for Frank, then translating that into a JSON Schema contract strict enough to produce clean database rows, inside a Workspace where I had no elevated admin rights and no margin for PHI leakage. The metrics are downstream of that translation. The engineering question was whether a hand-written rubric could become a repeatable system without losing what made it useful. It can.&lt;/p&gt;</content:encoded></item><item><title>How I built a clinic dashboard people actually trust</title><link>https://sampreethavvari.github.io/posts/cowork-dashboard/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/cowork-dashboard/</guid><description>Two clinics, three messy boards, and a patient-to-lead match that was only 49% right. The fix wasn&apos;t a clever algorithm. It was reading the data properly.</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The previous dashboard hit Monday.com’s live API directly. It broke on rate limits, broke on schema changes, broke whenever a column got renamed. Every Monday morning the front office would open it and see stale numbers or an error. Leadership would pull their own exports. Two people would walk into the same meeting with different totals for the same metric, and the meeting would turn into an argument about the spreadsheet instead of the business.&lt;/p&gt;
&lt;p&gt;At some point the front office stopped trusting their own reports. That is a worse problem than a broken API call.&lt;/p&gt;
&lt;p&gt;I’m the sole engineer touching this system at Hybridge. The move I kept resisting was the obvious one: stop fighting the live API and pull from the weekly Excel exports Monday already generates. Stable files, predictable schema, no rate limits. Once I committed to that, the real problem became visible: the join was wrong.&lt;/p&gt;
&lt;p&gt;Our dental practice runs two clinics. Each has a Patients board on Monday, both linked back to a single Leads board. To answer any real business question you have to join Patients to Leads. The old dashboard joined them by name. Two real people share a name. Typos happen. Monday inserts &lt;code&gt;(copy)&lt;/code&gt; on duplicates.&lt;/p&gt;
&lt;p&gt;I measured the damage one afternoon and almost didn’t believe the number:&lt;/p&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Board&lt;/th&gt;&lt;th&gt;Name-based join&lt;/th&gt;&lt;th&gt;After the fix&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Rochester Patients&lt;/td&gt;&lt;td&gt;&lt;strong&gt;49.5%&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;98.8%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Buffalo Patients&lt;/td&gt;&lt;td&gt;&lt;strong&gt;65.3%&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;99.9%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A third to half of our patients weren’t being linked to the original lead. Every funnel on every tab was off by a different amount. Leadership meetings were arguments about whose number was right.&lt;/p&gt;
&lt;h2&gt;The fix was a column I’d been looking at for months&lt;/h2&gt;
&lt;p&gt;Both Patient boards already had a real pointer field back to the Leads board. Monday calls it a &lt;em&gt;board_relation&lt;/em&gt;. The API hides it. The Excel export shows it.&lt;/p&gt;
&lt;p&gt;I switched the join from “match by name + creation date” to “match by the linked lead’s name string.” That one change took the linkage to 99%.&lt;/p&gt;
&lt;p&gt;The 25 leftover patients were re-treatment cases the ops team had filed without creating a Lead row. Combined treatment value: &lt;strong&gt;~$460k of patient value&lt;/strong&gt; that nobody had been attributing.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;~$460k&lt;/div&gt;
  &lt;div&gt;Orphan patient value surfaced after a single schema fix&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;One source of truth, finally&lt;/h2&gt;
&lt;p&gt;The old dashboard had filtering logic copy-pasted into eight tabs. They drifted. I rebuilt the data layer around three flat tables, one per Monday board, pulled from weekly Excel exports. Every metric lives in exactly one function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; getLeads_&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;snap&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  let&lt;/span&gt;&lt;span&gt; n &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; 0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; l&lt;/span&gt;&lt;span&gt; of&lt;/span&gt;&lt;span&gt; snap.leads) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;locOk&lt;/span&gt;&lt;span&gt;(l.location)) &lt;/span&gt;&lt;span&gt;continue&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;excluded&lt;/span&gt;&lt;span&gt;(l.reason_not_scheduled)) &lt;/span&gt;&lt;span&gt;continue&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;inRange&lt;/span&gt;&lt;span&gt;(l.creation_date, f.start, f.end)) &lt;/span&gt;&lt;span&gt;continue&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    n&lt;/span&gt;&lt;span&gt;++&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt; n;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Change the definition of “marketing source” in one place and every chart on every tab updates. Two tabs cannot disagree about the same number anymore.&lt;/p&gt;
&lt;h2&gt;What changed&lt;/h2&gt;
&lt;p&gt;A weekly workflow that used to eat 6-8 hours of senior time across the team now takes 3 minutes. The leadership meetings are about strategy, not arithmetic.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;49% → 99%&lt;/div&gt;
    &lt;div&gt;patient-to-lead linkage, both boards&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;½ day → 3 min&lt;/div&gt;
    &lt;div&gt;weekly reconciliation, end to end&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;8 → 1&lt;/div&gt;
    &lt;div&gt;filter logics across tabs&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The dashboard has six tabs: Dashboard, Monthly, History, Lead Sources, Trends, Playground. All pull from the same in-memory snapshot and the same metric functions. About 4,200 lines of Apps Script, deployed internally, costs nothing to run.&lt;/p&gt;
&lt;p&gt;The front office uses this dashboard every day. They do not think about how it works. They open it, see one number, and trust it. That is only possible because every business rule lives in one function. When leadership asks “why does this tab say X,” there is exactly one place to look. The orphans do not disappear silently anymore. If a re-treatment patient has no Lead row, the dashboard shows them as an orphan instead of dropping them from the funnel. That is the job: not shipping code, but making sure the business can see itself clearly.&lt;/p&gt;</content:encoded></item><item><title>How I taught the consult grader to write report cards</title><link>https://sampreethavvari.github.io/posts/doctor-report-cards/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/doctor-report-cards/</guid><description>We already score every patient consult. Now an AI reads all those scores and writes each doctor a coaching report card, every Monday at 8 AM, with nobody clicking anything. Plus the five bugs I squashed on the way.</description><pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Last month I wrote about &lt;a href=&quot;/posts/clinical-rag&quot;&gt;the pipeline that grades every patient consult&lt;/a&gt;: a Zoom call ends, Gemini scores it against the CEO’s seven-criterion rubric, and a color-coded report lands in three inboxes within fifteen minutes.&lt;/p&gt;
&lt;p&gt;That system has a quiet problem. It produces one report per consult. A doctor who did twelve consults last week got twelve separate emails, and the question that actually matters, &lt;em&gt;how is this doctor doing, overall, lately?&lt;/em&gt;, was still answered the old way: someone scrolling through a spreadsheet, squinting.&lt;/p&gt;
&lt;p&gt;So I built the layer on top. An AI reads all the scorecards we already saved and writes each doctor a &lt;strong&gt;coaching report card&lt;/strong&gt;. Then it compares the doctors to each other, nicely.&lt;/p&gt;
&lt;h2&gt;What it does&lt;/h2&gt;
&lt;p&gt;You pick any set of doctors and any date range: last &lt;strong&gt;7&lt;/strong&gt;, &lt;strong&gt;14&lt;/strong&gt;, or &lt;strong&gt;30&lt;/strong&gt; days, or your own. The system reads the stored scorecards. No re-watching videos, no re-reading transcripts; that work was already done at consult time.&lt;/p&gt;
&lt;p&gt;For each doctor it writes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;span&gt;✅&lt;/span&gt; strengths&lt;/li&gt;
&lt;li&gt;&lt;span&gt;⚠️&lt;/span&gt; things to improve&lt;/li&gt;
&lt;li&gt;a short summary across the same &lt;strong&gt;7 skill categories&lt;/strong&gt; the per-consult grader uses&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then comes the part a spreadsheet can’t do: a comparison across everyone. What the top performers are doing differently. What’s worth copying. What isn’t. And it closes with &lt;strong&gt;three green “do this” and three red “avoid this” examples&lt;/strong&gt;, each one pulled from a real consult, not invented.&lt;/p&gt;
&lt;h2&gt;The good part: it runs itself&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Every Monday at 8 AM&lt;/strong&gt; → last week’s report.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;First workday of the month, 8 AM&lt;/strong&gt; → last month’s report.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cloud Scheduler fires, the report writes itself, and it lands in the CEO’s inbox and mine. Nobody clicks a button. Nobody remembers to run anything.&lt;/p&gt;
&lt;p&gt;One small rule that matters more than it looks: a doctor with &lt;strong&gt;zero consults&lt;/strong&gt; in the window gets skipped. No empty pages, no “N/A across 7 categories” filler, no spam. The report only contains people there’s something to say about.&lt;/p&gt;
&lt;h2&gt;Five bugs I squashed on the way&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. The picky robot.&lt;/strong&gt; I reused the strict JSON Schema discipline from the per-consult grader: every response must validate, retry once on failure, then give up. For report cards, my schema was &lt;em&gt;too&lt;/em&gt; strict. The model failed validation twice in a row and the batch quit with only &lt;strong&gt;20% of the doctors&lt;/strong&gt; done. The fix was humbling: I showed the model one worked example of a valid report and loosened the rules that were strictness for strictness’s sake. Now it finishes every run. Schema validation is a great servant and a terrible master.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. The mystery email.&lt;/strong&gt; The first version of the email contained a link. The link opened a wall of raw JSON. Technically the report was “delivered.” &lt;span&gt;🤦&lt;/span&gt; Now the email &lt;em&gt;is&lt;/em&gt; the report, readable HTML right in the inbox, with a proper PDF attached for anyone who wants to file it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. No clock in the box.&lt;/strong&gt; “Every Monday at 8 AM” sounds simple until you ask the server &lt;em&gt;whose&lt;/em&gt; 8 AM. The slim container image I deployed had &lt;strong&gt;zero timezone data&lt;/strong&gt; in it. The moment the code asked for Eastern time, it would have crashed. Added the timezone package to the image. Crisis avoided before anyone got a 3 AM report card.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. The ghost upload.&lt;/strong&gt; My registry login had quietly expired. The image push &lt;em&gt;failed silently&lt;/em&gt;, and the deploy yelled “image not found” at an image I was sure I’d just built. Classic. Re-logged in, verified the push actually landed this time, shipped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. The ugly radar chart.&lt;/strong&gt; I gave each doctor a radar chart of their 7 categories. It looked bad. I deleted it. Zero regrets. Not every report needs a chart; some reports need fewer charts.&lt;/p&gt;
&lt;h2&gt;By the numbers&lt;/h2&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;0 clicks&lt;/div&gt;
    &lt;div&gt;weekly + monthly reports, fully automatic&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;411&lt;/div&gt;
    &lt;div&gt;tests passing&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;3 + 3&lt;/div&gt;
    &lt;div&gt;real &quot;do this&quot; and &quot;avoid this&quot; examples per report&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Before&lt;/th&gt;&lt;th&gt;After&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Per-doctor coaching view&lt;/td&gt;&lt;td&gt;Someone scrolls a spreadsheet&lt;/td&gt;&lt;td&gt;Written report card, per doctor&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cross-doctor comparison&lt;/td&gt;&lt;td&gt;Didn’t exist&lt;/td&gt;&lt;td&gt;Every report, automatically&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Effort to produce it&lt;/td&gt;&lt;td&gt;Manual, whenever someone had time&lt;/td&gt;&lt;td&gt;0 clicks, Monday 8 AM + monthly&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Doctors with no consults&lt;/td&gt;&lt;td&gt;Empty rows&lt;/td&gt;&lt;td&gt;Skipped entirely&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Same privacy rules as the rest of the stack: no patient information ever lands in our logs.&lt;/p&gt;
&lt;p&gt;Here’s the part worth noticing. Of the five bugs, exactly &lt;strong&gt;one&lt;/strong&gt; was about the AI. The other four were an email, a clock, a login, and a chart. That’s been the pattern across everything I’ve shipped at Hybridge: the model is rarely the hard part. The hard part is the plumbing around it, and the plumbing is where the thing earns the right to run unattended at 8 AM on a Monday with nobody watching.&lt;/p&gt;</content:encoded></item><item><title>Building an enterprise search that knows when to say &apos;I don&apos;t know&apos;</title><link>https://sampreethavvari.github.io/posts/enterprise-search/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/enterprise-search/</guid><description>Hybridge&apos;s knowledge is scattered across drives, decks, and people&apos;s heads. I&apos;m building an internal search that answers questions in plain language, with citations, and that refuses to guess when it isn&apos;t sure. This is how I&apos;m thinking about a RAG you can actually trust, built one stage at a time.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every company my size has the same quiet problem. The answer to your question exists. It is in a slide deck from eight months ago, or a policy doc nobody re-shares, or in one person’s head, and that person is on vacation. So you ask around, you guess, and sometimes you act on a version of the truth that got replaced in March.&lt;/p&gt;
&lt;p&gt;I am building the fix for Hybridge: an internal search where anyone signs in with their company account, types a question in plain language, and gets a real answer with the sources attached. We call it &lt;strong&gt;Ask Hybridge&lt;/strong&gt;. People who are allowed to add documents drop them into a portal, and those documents flow through a careful pipeline before they ever become searchable.&lt;/p&gt;
&lt;p&gt;The easy version of this is a weekend project now. You chunk some documents, embed them, stuff the top matches into a prompt, and call it RAG. I did not want the easy version. We work in healthcare, which means patient data, HR records, and trade secrets all live in the same building. In that setting a confident wrong answer is not a cute demo bug. It is a liability. So the whole design is organized around one idea: the system should be trustworthy first, and clever second.&lt;/p&gt;
&lt;h2&gt;The one rule everything serves&lt;/h2&gt;
&lt;p&gt;Here is the principle I keep coming back to. The system should answer from what it actually found, and when it did not find enough, it should say so. No filling the gap with a plausible guess. An honest “I don’t know, here is the closest thing” is a feature, not a failure.&lt;/p&gt;
&lt;p&gt;That sounds obvious. It is surprisingly hard, and almost every shortcut in RAG quietly breaks it.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;trust first, clever second&lt;/div&gt;
  &lt;div&gt;Every stage either makes the answer more trustworthy (hybrid retrieval, reranking, conflict resolution, citations, grounding) or it stays off the hot path until the evaluation says it earns its place.&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;Why I am not building a “basic” RAG&lt;/h2&gt;
&lt;p&gt;A basic RAG fails in three ways that matter in a real company, so I planned for all three from the start.&lt;/p&gt;
&lt;p&gt;It fails on exact terms. Pure vector search is great at meaning and bad at specifics. Search a part number, a policy code, or a person’s name and a semantic-only index will hand you something that “feels related” instead of the exact match. The fix is to run keyword search and vector search together.&lt;/p&gt;
&lt;p&gt;It fails on conflicts. Two documents disagree. One is the current SOP, one is a draft from last year that nobody deleted. A naive system picks whichever happens to rank higher and presents it with total confidence. That is the dangerous one.&lt;/p&gt;
&lt;p&gt;It fails on permissions. The marketing team should never retrieve a document restricted to the clinical group, even by accident, even as “context” the model quietly reads. Access has to be enforced before retrieval, not bolted on after.&lt;/p&gt;
&lt;h2&gt;How a question gets answered (the read path)&lt;/h2&gt;
&lt;p&gt;When someone asks a question, the answer travels a fixed path. I kept the common case fast and simple, and put the expensive, clever steps behind a gate so they only run when a question actually needs them.&lt;/p&gt;
&lt;div&gt;
&lt;strong&gt;The read path, in order.&lt;/strong&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Rewrite&lt;/strong&gt; the question only if it is vague or part of a back-and-forth chat. A well-formed question skips this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filter by permission&lt;/strong&gt; first, as a database condition. Documents you are not allowed to see never enter the search.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retrieve two ways at once&lt;/strong&gt;: keyword search (BM25) for exact terms, vector search for meaning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Merge&lt;/strong&gt; the two result lists with Reciprocal Rank Fusion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rerank&lt;/strong&gt; a small top slice with a dedicated ranking model, for precision without paying the latency on everything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resolve conflicts&lt;/strong&gt; deterministically (see below).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Answer&lt;/strong&gt;, citing the exact passages. If the evidence is thin, abstain.&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;p&gt;Most questions are simple lookups, and they take the fast path and finish. Only the hard ones, the low-confidence or multi-step questions, trigger a corrective loop: re-plan, retrieve again, fall back to a web search if the answer genuinely is not in our knowledge, check the draft answer against the evidence, and only then respond.&lt;/p&gt;
&lt;p&gt;I want to be clear about why that loop is gated rather than always-on. An agent that re-plans on every single query feels smart and behaves badly. It is slower, it costs more, and it pulls in noisier context that can make a simple answer worse. So the expensive behavior only switches on when a confidence check says the simple path did not work. The default is fast and boring. The cleverness is held in reserve.&lt;/p&gt;
&lt;h2&gt;When sources disagree, the model does not get to pick&lt;/h2&gt;
&lt;p&gt;This is the part I am most careful about. When two retrieved passages make conflicting claims, the model is never allowed to just choose one. A small piece of plain code ranks the evidence first, by rules anyone can read.&lt;/p&gt;
&lt;div&gt;
&lt;strong&gt;The conflict ladder (deterministic).&lt;/strong&gt;
&lt;ol&gt;
&lt;li&gt;Group the evidence by what each piece actually claims.&lt;/li&gt;
&lt;li&gt;Drop anything marked superseded or deprecated. A replaced document cannot win.&lt;/li&gt;
&lt;li&gt;Higher authority wins (an official policy outranks a personal note).&lt;/li&gt;
&lt;li&gt;If authority ties, the most recent effective date wins.&lt;/li&gt;
&lt;li&gt;If it still cannot be resolved, declare the disagreement: show both answers with their author, date, and source, and say plainly that the sources disagree.&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;p&gt;The model’s job is only to notice that two passages genuinely contradict each other and to write the answer in a way that honors that ranking, or to surface the disagreement honestly. The decision about which source is more authoritative is made by rules, not vibes. That is the difference between a search you can run a company on and a search that sounds confident.&lt;/p&gt;
&lt;h2&gt;What happens when a document comes in (the write path)&lt;/h2&gt;
&lt;p&gt;Retrieval is only as good as what got indexed, so the ingestion side gets the same care. A document does not become searchable just because someone uploaded it.&lt;/p&gt;
&lt;div&gt;
&lt;strong&gt;The write path, in order.&lt;/strong&gt; Store the original safely and fingerprint it. Skip it if it is a duplicate. Parse it with layout awareness so tables and slides survive. Chunk it on real topic and structure boundaries, not blind fixed windows. Attach metadata and a short &quot;document card&quot; (a title, a one-paragraph summary, key entities, the effective date, who can see it). Embed in batches. Index the vectors, the keyword text, and the permissions together.
&lt;/div&gt;
&lt;p&gt;Two details there do a lot of quiet work. The &lt;strong&gt;document card&lt;/strong&gt; stays small no matter how long the document is, so the system can route and display a document without re-reading the whole thing. And every chunk is stamped with the version of the embedding model and chunking strategy that produced it, so when I improve either one later, I can re-index safely instead of guessing what is stale.&lt;/p&gt;
&lt;h2&gt;The part most people skip: measuring it&lt;/h2&gt;
&lt;p&gt;Here is the question that started the whole project for me. How do I know the answer is good before I trust it? Most RAG demos never answer that. They look impressive in a screenshot and nobody checks the hit rate.&lt;/p&gt;
&lt;p&gt;So I am building an evaluation harness alongside the system, not after it. It measures retrieval with the standard metrics (did the right document show up, and how high), and it measures the generation for faithfulness, whether every claim in the answer is actually backed by a cited source. It even runs a managed search product in parallel as a yardstick, so I can compare what I built against a strong baseline and know whether my extra machinery is earning its keep.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;recall · nDCG · MRR&lt;/div&gt;
    &lt;div&gt;did the right source come back, and did it rank near the top&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;faithfulness&lt;/div&gt;
    &lt;div&gt;is every sentence in the answer grounded in a citation, or did the model improvise&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;vs. a managed baseline&lt;/div&gt;
    &lt;div&gt;my pipeline runs next to an off-the-shelf one so the comparison is honest&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The harness is the referee. It is what decides when a fancier idea actually goes into production and when it stays a nice diagram. That rule is how I keep an ambitious system from turning into an over-engineered one.&lt;/p&gt;
&lt;h2&gt;The stack, in one box&lt;/h2&gt;
&lt;div&gt;
&lt;strong&gt;Built on Google Cloud, open-source and GCP-native only (no third-party SaaS).&lt;/strong&gt; Identity Platform for domain-locked Google sign-in. Cloud Run for the API and pipeline jobs, with Pub/Sub and Eventarc for event-driven ingestion. Document AI for layout-aware parsing. Vertex AI for embeddings, reranking, and Gemini for generation (a bigger model for ingestion, a fast one for most answers). Postgres on Cloud SQL holding the vectors (pgvector), the keyword index, the metadata, and the access rules in one place. Terraform for the infrastructure, Cloud Build for CI/CD. Everything chosen is HIPAA-eligible under a Google business associate agreement, with customer-managed encryption and full audit logging.
&lt;/div&gt;
&lt;p&gt;I deliberately started on Cloud SQL with pgvector instead of a heavier specialized vector database. It meets every need we have at our current size of hundreds to low thousands of documents, at roughly half the cost, and there is a clean upgrade path the day the evaluation harness tells me I have outgrown it. That is the theme again: pick the simple thing, and let the measurements, not the hype, tell me when to add complexity.&lt;/p&gt;
&lt;h2&gt;Where it stands, with no rounding up&lt;/h2&gt;
&lt;p&gt;I am building this the way you would teach it, one stage at a time, because each stage is also a chunk of RAG I wanted to learn properly by hand.&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Stage&lt;/th&gt;&lt;th&gt;What it owns&lt;/th&gt;&lt;th&gt;Status&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Foundation and auth&lt;/td&gt;&lt;td&gt;GCP setup, domain-locked sign-in, roles and groups, secrets, CI/CD, infrastructure as code&lt;/td&gt;&lt;td&gt;Shipped&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Ingestion&lt;/td&gt;&lt;td&gt;upload, dedup, parse, chunk, metadata, embed, index&lt;/td&gt;&lt;td&gt;Designed, in build&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query serving&lt;/td&gt;&lt;td&gt;rewrite, filter, hybrid retrieval, rerank, conflict resolution, grounded answer&lt;/td&gt;&lt;td&gt;Designed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Evaluation&lt;/td&gt;&lt;td&gt;retrieval and faithfulness metrics, conflict tests, managed baseline&lt;/td&gt;&lt;td&gt;Grows alongside the two above&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Search and Dropoff front ends&lt;/td&gt;&lt;td&gt;the chat experience and the upload portal&lt;/td&gt;&lt;td&gt;Designed&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;What is real today: the foundation and authentication layer is shipped and running. Sign-in is locked to the company domain, roles and groups are enforced, and the whole thing stands up from a clean checkout through Terraform and a tested CI/CD pipeline. The ingestion pipeline is fully specified and in build. The rest is designed in detail and queued behind it.&lt;/p&gt;
&lt;p&gt;That is the honest status. I would rather show you a trustworthy foundation and a clear plan than a flashy demo that falls over the first time two documents disagree. The whole point of this system is to not do that.&lt;/p&gt;</content:encoded></item><item><title>How I caught fake news and read the mood of an election</title><link>https://sampreethavvari.github.io/posts/fake-news-sentiment/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/fake-news-sentiment/</guid><description>I built two models for the 2020 election: one to flag fake news, one to read how people felt across 1.8 million tweets. The modeling was the easy part.</description><pubDate>Sun, 12 May 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everyone says they want to “detect fake news.” That phrase is hiding two completely different jobs.&lt;/p&gt;
&lt;p&gt;Job one: someone makes a claim. Is it true? That’s a fact-checking problem.&lt;/p&gt;
&lt;p&gt;Job two: a million people are tweeting about that claim. How do they feel, and what are they actually mad about? That’s a mood problem.&lt;/p&gt;
&lt;p&gt;For a grad-school project on the 2020 US election, I built both. And the fun part is: the AI models were the easy bit. The hard, messy, interesting work was everywhere else.&lt;/p&gt;
&lt;h2&gt;Two jobs, two models&lt;/h2&gt;
&lt;p&gt;I didn’t try to make one giant model do everything. That almost never works. Instead I picked the right tool for each job.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;                  text comes in&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                       │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ┌──────────────┴──────────────┐&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ▼                             ▼&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;   Is this claim fake?           How do people feel?&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;   Llama 3 (8B)                  RoBERTa&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;   trained on labeled claims     trained on tweets&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │                             │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        └──────────────┬──────────────┘&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                       ▼&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              What are they arguing about?&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              (topic grouping)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the &lt;strong&gt;fake-news job&lt;/strong&gt;, I needed a model that understands the meaning of a short, slippery political statement. I used Llama 3, a big language model, and trained it on a public set of claims that experts had already labeled true or false.&lt;/p&gt;
&lt;p&gt;For the &lt;strong&gt;mood job&lt;/strong&gt;, I had 1.8 million tweets and zero patience for a slow, expensive model running on all of them. So I used RoBERTa, a smaller, faster model that’s great at reading tone. The right tool isn’t always the biggest one. At 1.8 million tweets, “cheap and fast” wins.&lt;/p&gt;
&lt;h2&gt;The cheap trick that made the big model affordable&lt;/h2&gt;
&lt;p&gt;Training an 8-billion-parameter model sounds like it needs a server farm. It doesn’t, if you’re a little clever.&lt;/p&gt;
&lt;p&gt;I used a trick called &lt;strong&gt;QLoRA&lt;/strong&gt;. The short version: instead of retraining the whole giant model (expensive, slow, needs lots of memory), you freeze it and train a tiny add-on layer on top. The big model stays put; you only teach the small new piece. It fits on a single regular GPU, and the thing you ship at the end is a few megabytes instead of gigabytes.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;76%&lt;/div&gt;
    &lt;div&gt;accuracy on hard, expert-labeled claims&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;1 GPU&lt;/div&gt;
    &lt;div&gt;all it took, thanks to the QLoRA trick&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;megabytes&lt;/div&gt;
    &lt;div&gt;the size of what I actually shipped&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;76% accuracy&lt;/strong&gt; might not sound flashy, but these claims are short and politically loaded with no extra context to lean on. They’re genuinely hard. If I’d reported 95%, I’d have assumed I’d cheated somewhere by accident.&lt;/p&gt;
&lt;h2&gt;What actually broke (and what it taught me)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The model learned to be lazy.&lt;/strong&gt; The labeled claims weren’t evenly split, so my first model figured out it could look smart just by guessing the most common answer. Accuracy looked fine; the model was useless. The fix was to stop trusting one number and start checking how it did on &lt;em&gt;each&lt;/em&gt; type of claim. Lesson that stuck: a single accuracy score can lie to your face.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My two models disagreed on basic cleanup.&lt;/strong&gt; Early on, the tweet cleaner and the claim cleaner were slightly different, so “the same sentence” got chopped up two different ways depending on which model saw it. I merged them into one shared cleanup step. Boring fix, big difference.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My “topics” were mostly punctuation.&lt;/strong&gt; I also grouped the tweets by topic to see what people were arguing about. The first run proudly told me the hottest topics were… hashtags and exclamation marks. After cleaning those out, real themes finally showed up.&lt;/p&gt;
&lt;h2&gt;So what’s the actual takeaway?&lt;/h2&gt;
&lt;p&gt;The flashy way to describe this is “I fine-tuned an LLM to detect fake news.”&lt;/p&gt;
&lt;p&gt;The honest way is “I figured out there were two different problems, used a cheap fast model for the big firehose and a smart one only where it mattered, and refused to believe a single accuracy number without looking underneath it.”&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The model is the footnote. Knowing which problem you’re solving, and how you’ll know you solved it, is the actual job.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That’s the habit I took from this one: when someone hands you a big fuzzy goal like “detect fake news,” the first move isn’t to grab a model. It’s to ask what you’re really being asked to do.&lt;/p&gt;
&lt;p&gt;Full project: &lt;a href=&quot;https://hi.switchy.io/U4wO&quot;&gt;Fake News and Sentiment Analysis&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>How making movies made me a better engineer</title><link>https://sampreethavvari.github.io/posts/film-and-engineering/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/film-and-engineering/</guid><description>People keep telling me films and engineering are separate. They&apos;re not. Here&apos;s what ten years of making movies did to the way I build.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I make movies. I write screenplays, direct, edit, occasionally compose music. I also build production AI systems at a dental implant company. People keep treating those two as separate. They aren’t.&lt;/p&gt;
&lt;blockquote&gt;
The customer is the cast. The system is the scene. The job is to make the scene work for them.
&lt;/blockquote&gt;
&lt;p&gt;Here’s the credit list, so we’re talking about specifics:&lt;/p&gt;













































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Year&lt;/th&gt;&lt;th&gt;Project&lt;/th&gt;&lt;th&gt;Role&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;2026&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Among Monsters&lt;/strong&gt; (&lt;a href=&quot;https://www.imdb.com/title/tt39700295/&quot;&gt;IMDb&lt;/a&gt;)&lt;/td&gt;&lt;td&gt;Director / Writer / Editor / Color / Music&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;upcoming&lt;/td&gt;&lt;td&gt;Extraordinary Lives&lt;/td&gt;&lt;td&gt;Director / Writer&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;upcoming&lt;/td&gt;&lt;td&gt;Pupa&lt;/td&gt;&lt;td&gt;Writer&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2025&lt;/td&gt;&lt;td&gt;Solistice&lt;/td&gt;&lt;td&gt;Editor&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2024&lt;/td&gt;&lt;td&gt;Swecha&lt;/td&gt;&lt;td&gt;Editor&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2022&lt;/td&gt;&lt;td&gt;Tiger Man&lt;/td&gt;&lt;td&gt;Director / Writer / Editor&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2019&lt;/td&gt;&lt;td&gt;Strangers&lt;/td&gt;&lt;td&gt;Cinematographer / Music&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;I’m cutting &lt;em&gt;Among Monsters&lt;/em&gt; between work weeks right now.&lt;/p&gt;
&lt;h2&gt;A film set looks like an engineering room&lt;/h2&gt;
&lt;p&gt;A small short has a director, a writer, a DP, a sound mixer, a gaffer, a costumer, an art department, an editor. Each one speaks a different vocabulary. Each one defines “done” differently. Your job as director is not to know how each department does its job. Your job is to translate the scene you’re trying to make into the eight languages on your set.&lt;/p&gt;
&lt;p&gt;This is the same situation I walk into at work. The senior treatment coordinator who’s quoted full-arch implant cases for ten years doesn’t care about my Postgres trigger. She cares whether the system gives her the same number she quoted last month. The controller doesn’t care about my retry-with-backoff decorator. He cares whether the bank CSV makes it into the cashflow sheet before his 5pm cutoff.&lt;/p&gt;
&lt;p&gt;Specialists. None of them wrong. One person whose job is to make it add up.&lt;/p&gt;
&lt;h2&gt;Things I do at a keyboard that I learned on set&lt;/h2&gt;
&lt;div&gt;
&lt;p&gt;&lt;strong&gt;Block before you light.&lt;/strong&gt; On set, you walk the actors through the scene before anyone touches a lamp. In code, I write the ADR before I touch the schema. Every project I’m proud of has a spec written first. Building from a shape you’ve sketched costs less than building from a shape you haven’t.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;p&gt;&lt;strong&gt;Listen to the script supervisor.&lt;/strong&gt; The script supervisor has the lowest credit on the call sheet and the highest practical authority on the day. The engineering equivalent is the person who does the actual work every day. Garrett knew the bank CSV glob picks up half-downloaded files. I would not have built the y/s/d prompt in the cashflow import if he hadn’t told me.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;p&gt;&lt;strong&gt;Cut to the bone.&lt;/strong&gt; Editing a film is mostly about removing. A 20-minute short usually starts as a 45-minute assembly. The cuts that hurt to make are usually the right ones. The same with code. The dashboard rebuild replaced 2,803 lines of live-API code with 1,400 lines of weekly-Excel code that’s strictly more correct. Less is harder than more.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;p&gt;&lt;strong&gt;Show one cut to the right person.&lt;/strong&gt; The first person I show a rough cut to isn’t another editor. It’s somebody from the target audience. I don’t ask for notes. I watch them watch it. Same thing in code: the first demo of a new feature isn’t to the engineering team. It’s to the person who’s going to use it every day. I’d rather watch Chelsea fumble through the tooth chart for thirty seconds in silence than read a stakeholder Slack thread about it.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;p&gt;&lt;strong&gt;A scene works or it doesn’t.&lt;/strong&gt; Films don’t get judged on the elegance of the camerawork. They get judged on whether the scene works. A great scene with one bad cut is still a great scene. A polished sequence of bad scenes is a polished bad film. I’m relentless about whether the shipped thing actually solved the problem the customer brought.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;What this does to the engineering portfolio&lt;/h2&gt;
&lt;p&gt;Three things, all downstream of the same instinct:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The customer is the cast. The system is the scene. The job is to make the scene work for them.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;The systems I ship survive contact with non-engineers.&lt;/strong&gt; Consider the quirks in the dashboard, the estimator, the consultation pipeline: the deliberate inclusion of “Hung up” leads in the Scheduled count, the orphan re-treatment patients shown in a Data Quality panel but still counted in CA, the three-affordance wizard orientation. None of those are technical decisions. They’re judgement calls from arguments with the people who’d be using the thing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I can work alone in scopes that would normally need a team.&lt;/strong&gt; A director on a small short is the writer, the editor, the colourist, the producer’s bad cop, the actor’s good cop. I went into engineering already used to wearing eight hats on a project. The single-person Hybridge work (CBCT validator + estimator + consultation pipeline + dashboard + accounting suite) wouldn’t have been possible without that muscle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The post-mortem is part of the work.&lt;/strong&gt; Every film I’ve made gets re-watched a year later with a list of “what didn’t work and why.” Every system I’ve shipped has a &lt;code&gt;BACKLOG.md&lt;/code&gt; of consciously-deferred items with revisit triggers. Same instinct: name the loose ends out loud, then come back when the moment is right.&lt;/p&gt;
&lt;h2&gt;The version a hiring manager wants&lt;/h2&gt;
&lt;p&gt;If you’re hiring an engineer who can sit with a customer until the system you ship is the system they needed, that’s the work I want. The Hybridge portfolio is what comes out the other end when you treat every project like a small film. A spec written before the schema. A handful of department heads who all need to be heard. A scene that needs to actually work. A cut that gets shorter every week until it’s the right length.&lt;/p&gt;
&lt;p&gt;Two crafts, same muscle.&lt;/p&gt;</content:encoded></item><item><title>I built an open-source app to run my job hunt</title><link>https://sampreethavvari.github.io/posts/jobpilot-v2/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/jobpilot-v2/</guid><description>JobPilot finds fresh roles across 160+ company boards, scores each against my profile, writes a tailored resume and cover letter per match, and tracks every reply to offer. It&apos;s now open-source, and it ended up touching all four things I do: data, ML, software, and shipping.</description><pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A few days ago &lt;a href=&quot;/posts/jobpilot&quot;&gt;I built JobPilot in about five hours&lt;/a&gt;: a job-search autopilot that runs on GCP while I sleep. Then I actually used it, every day, and kept fixing what annoyed me. It has since grown from a one-night hack into the thing my whole search runs on, so today I did the obvious last step and open-sourced it: &lt;strong&gt;&lt;a href=&quot;https://github.com/SampreethAvvari/job-pilot&quot;&gt;github.com/SampreethAvvari/job-pilot&lt;/a&gt;&lt;/strong&gt;, MIT, with a fork guide written to be handed straight to a coding agent.&lt;/p&gt;
&lt;p&gt;Here is what it does now, and why it quietly turned into four projects wearing one trench coat.&lt;/p&gt;
&lt;h2&gt;What a morning looks like&lt;/h2&gt;
&lt;p&gt;I open the console, sort by &lt;em&gt;recently posted&lt;/em&gt;, and run down one role category at a time. Each job already has a fit score, a one-line “why,” and the right tailored resume sitting one click away. I read the score’s reasoning, hit &lt;strong&gt;Apply ↗&lt;/strong&gt;, and the posting opens in a new tab. When I come back, the console asks whether I actually applied; yes stamps the date and moves it to &lt;strong&gt;Applied&lt;/strong&gt;. An irrelevant job gets one &lt;strong&gt;✕&lt;/strong&gt; and is gone forever, but logged, so the dismissals become filter training data later.&lt;/p&gt;
&lt;p&gt;That is the whole daily ritual. No scrolling LinkedIn, no rereading the same reposted listing, no tab graveyard. The machine did the finding, scoring, and writing overnight; I do the deciding.&lt;/p&gt;
&lt;h2&gt;Where the jobs actually come from&lt;/h2&gt;
&lt;p&gt;The trick is still to skip the aggregator lag and read companies’ own boards. JobPilot now watches &lt;strong&gt;160+ company career pages directly&lt;/strong&gt; across seven ATS types (Greenhouse, Lever, Ashby, Workday, SmartRecruiters, Workable, Recruitee), plus RemoteOK, Hacker News “Who is Hiring,” Adzuna, and LinkedIn through a pay-per-result Apify actor. Adding a company is one row in a watchlist: I paste a name and a careers URL, a resolver works out which of the seven ATSs it runs on, and that board joins the next poll. Eleven source adapters, one normalized job behind them. Free sources get polled &lt;strong&gt;every hour&lt;/strong&gt;; the full run (LinkedIn, tailoring, outreach, digest) fires &lt;strong&gt;four times a day&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Two walls run before any compute is spent. A regex wall drops citizenship, clearance, and “cannot sponsor” postings, plus senior/staff/contract titles I’d never apply to. A new &lt;strong&gt;US-only location wall&lt;/strong&gt; drops non-US roles entirely, and it knows that a state code beats a city-name collision, so “Vancouver, WA” stays and “Vancouver, BC” goes. A job posted on a company’s board this morning is in my console by the next hourly run.&lt;/p&gt;
&lt;h2&gt;The scorer, and the resume judge that argues back&lt;/h2&gt;
&lt;p&gt;Every surviving job goes to Vertex AI Gemini under a JSON-schema contract: fit 0 to 100, the reasoning, a sponsorship signal, a role category, and which of my four resume variants fits best. Anything scoring 60+ gets tailored automatically.&lt;/p&gt;
&lt;p&gt;The piece I’m proudest of is the judge. My first version’s “ATS check” gave my real resume a 100 while &lt;a href=&quot;/posts/jobpilot&quot;&gt;ResumeWorded gave it a 73&lt;/a&gt;. So I rebuilt the grader as a replica: about &lt;strong&gt;40 deterministic checks&lt;/strong&gt; weighted impact 35, brevity 20, style 15, sections 15, soft-skills 15. Then every resume, master or per-job tailored, runs a judge-guided rewrite loop:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;judge → violations list → Gemini rewrites (truth-locked) → recompile → judge again&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Up to ten attempts, early exit at 90, and the best attempt wins. A rewrite that scores worse is thrown away; the system can only ever publish an improvement. The rewrites are truth-locked: reorder, rephrase, and re-emphasize, yes; invent an employer, a date, or a metric, never. My real score went 73 to 82 on one calibration pass, and every application ships with its own ATS report you can open in the console.&lt;/p&gt;
&lt;h2&gt;The copilot&lt;/h2&gt;
&lt;p&gt;The biggest addition since launch is a grounded chat assistant. It loads a knowledge pack (my GitHub, this portfolio, my resumes, my profile) and answers through a guardrailed Vertex chat, so it talks about &lt;em&gt;my&lt;/em&gt; search with real context instead of guessing. Every job row also has a &lt;strong&gt;&lt;span&gt;💬&lt;/span&gt;&lt;/strong&gt; that opens its own independent chat drawer with the live job description fetched on the spot, and I can attach a PDF or an image (a JD screenshot, a recruiter’s note) straight into the conversation. There’s also a manual-job tailor flow for the role a friend forwards that no board ever surfaced.&lt;/p&gt;
&lt;h2&gt;More than a table&lt;/h2&gt;
&lt;p&gt;The console grew with the search. It is no longer one screen but a small app: a jobs registry I triage every morning, an applied funnel that advances itself as replies arrive, a replies feed, a resume armory that shows each of the four variants with its live ATS score and a one-click rebuild, a companies watchlist with per-board health, and an outreach console where the drafts wait. The Google Sheet underneath is still the database, so I can fix anything from my phone and the console just reflects it.&lt;/p&gt;
&lt;h2&gt;It never sends anything on its own&lt;/h2&gt;
&lt;p&gt;Two rules I refuse to ship without. The system &lt;strong&gt;never sends an email by itself&lt;/strong&gt;: recruiter outreach is looked up through Apollo, written, and dropped into my Gmail drafts for me to send. For a company on my watchlist I have not applied to yet, it can find two or three hiring contacts and draft a cold intro the same way. Every one waits in drafts until I read it and hit send. And automation &lt;strong&gt;never overwrites my manual edits&lt;/strong&gt;; it only moves a status forward.&lt;/p&gt;
&lt;p&gt;The reply tracking got smarter too. An hourly inbox watcher reads the last couple of days across every inbox I connect, and a genuine next step (interview invite, scheduling request, assessment) triggers an &lt;strong&gt;instant alert email&lt;/strong&gt; telling me which company replied in which inbox, with a link to the exact message. Rejections quietly update the tracker; “thanks for applying” autoresponders are ignored.&lt;/p&gt;
&lt;h2&gt;Why it ended up being four projects in one&lt;/h2&gt;
&lt;p&gt;This is the part that surprised me. JobPilot isn’t an AI project or a web project. It’s all four things I do, stacked:&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;Data + ML&lt;/div&gt;
    &lt;div&gt;11 source adapters behind one schema; schema-locked Gemini scoring, a calibrated judge, and a truth-locked rewrite loop&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;Software&lt;/div&gt;
    &lt;div&gt;Next.js 16 console behind IAP, a Cloud Run pipeline, 50+ tests, keyless CI/CD on Workload Identity Federation&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;Shipping&lt;/div&gt;
    &lt;div&gt;I was the user, so the spec was &quot;what annoyed me yesterday.&quot; Every feature earned its place by surviving the next morning&apos;s search&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;That’s the data engineer, the ML engineer, the software engineer, and the forward-deployed instinct of building the thing that actually gets used, in a single repo. It’s the most honest portfolio piece I have, because I run it on myself.&lt;/p&gt;
&lt;h2&gt;Fork it&lt;/h2&gt;
&lt;p&gt;The whole thing is public under MIT: &lt;strong&gt;&lt;a href=&quot;https://github.com/SampreethAvvari/job-pilot&quot;&gt;github.com/SampreethAvvari/job-pilot&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;fork guide&lt;/strong&gt; (&lt;code&gt;docs/FORK-SETUP.md&lt;/code&gt;) written to be handed directly to an AI coding agent, covering every gcloud command, every OAuth click, and exactly which Google scopes it needs and why (drafts-only email, never auto-send).&lt;/li&gt;
&lt;li&gt;A structured &lt;strong&gt;bug log&lt;/strong&gt; of everything I hit, so nobody pays the same tuition: the XeTeX PDFs that ATS parsers read as “New Y ork,” the Docker CMD that ate my job arguments, the 163-board parallel fetch that OOM’d at the default memory.&lt;/li&gt;
&lt;li&gt;Zero secrets and zero personal data in the repo or its history. My resumes and profile live in Secret Manager; the repo ships a Jane Doe template.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;By the numbers&lt;/h2&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;160+ boards&lt;/div&gt;
    &lt;div&gt;company pages watched directly, plus 4 aggregator sources&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;73 → 82&lt;/div&gt;
    &lt;div&gt;real ResumeWorded score after one calibration pass&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;~1¢&lt;/div&gt;
    &lt;div&gt;per tailored resume + cover letter, rewrite loop included&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;A normal job search&lt;/th&gt;&lt;th&gt;JobPilot&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Job freshness&lt;/td&gt;&lt;td&gt;days to weeks old, reposts&lt;/td&gt;&lt;td&gt;hours old, straight from company boards&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Sponsorship dead ends&lt;/td&gt;&lt;td&gt;you read every one&lt;/td&gt;&lt;td&gt;dropped before they reach you&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Resume per application&lt;/td&gt;&lt;td&gt;one generic PDF&lt;/td&gt;&lt;td&gt;1 of 4 variants, tailored and judged, ~1¢&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Tracking&lt;/td&gt;&lt;td&gt;a spreadsheet you forget&lt;/td&gt;&lt;td&gt;statuses update from your inbox&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;When a recruiter replies&lt;/td&gt;&lt;td&gt;you notice eventually&lt;/td&gt;&lt;td&gt;instant alert, with the message linked&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The first post said a job search is a pipeline problem, not a willpower problem. Three days of using my own pipeline taught me the sequel: a job search is also a &lt;em&gt;product&lt;/em&gt; problem. The reason it works is not the model. It’s the hundred small decisions around the model about what to drop, what to trust, and what to leave to me. So I open-sourced all of them.&lt;/p&gt;</content:encoded></item><item><title>How I built a job-search autopilot in 5 hours</title><link>https://sampreethavvari.github.io/posts/jobpilot/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/jobpilot/</guid><description>Refreshing LinkedIn for jobs that were posted three weeks ago is not a strategy. So I built JobPilot: 7 job sources, 4 runs a day, 4 tailored resumes, one dashboard, for under $5 a month.</description><pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here’s what applying from LinkedIn alone actually looks like: a posting says “2 weeks ago,” it’s been reposted twice, 1,000+ people clicked Apply, and (the part that stings if you’re an international student) paragraph six says &lt;em&gt;“unable to provide visa sponsorship.”&lt;/em&gt; You read all of it anyway. Multiply by fifty, every day, forever.&lt;/p&gt;
&lt;p&gt;I decided my evenings were worth more than that. So one night I sat down with Claude Code and built &lt;strong&gt;JobPilot&lt;/strong&gt;, a job-search autopilot that runs on GCP while I sleep. Total build time: &lt;strong&gt;about 5 hours&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Where the jobs actually come from&lt;/h2&gt;
&lt;p&gt;The trick is to skip the aggregator lag and go to the source. JobPilot pulls from &lt;strong&gt;7 sources&lt;/strong&gt;, almost all free:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Greenhouse, Lever, and Ashby&lt;/strong&gt;: the ATS boards companies &lt;em&gt;actually post to first&lt;/em&gt;. I watch &lt;strong&gt;39 companies&lt;/strong&gt; directly: Stripe, Anthropic, OpenAI, Databricks, Notion, Ramp, Figma, Datadog, Palantir, Cursor… These APIs are public, keyless, and free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hacker News “Who is Hiring”&lt;/strong&gt;: free via the Algolia API.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RemoteOK&lt;/strong&gt;: free public JSON.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adzuna&lt;/strong&gt;: free API key, capped to postings ≤ 2 days old.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LinkedIn&lt;/strong&gt;: yes, it’s still in there, via an Apify scraper actor on free credits, capped at 50 results from the last 24 hours so it never costs real money.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A job posted on a company’s Greenhouse board this morning is in my inbox by the next run. That’s the whole point: &lt;strong&gt;I see jobs hours old, not weeks old.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The pipeline (runs 4× a day)&lt;/h2&gt;
&lt;p&gt;Cloud Scheduler kicks off a Cloud Run job at midnight, 6 AM, noon, and 6 PM ET:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;fetch (7 sources)          ~100 jobs per source, in parallel&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  → filter                 drop stale, senior, contract, no-sponsorship&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  → dedupe                 SHA1(company|title|location) across sources&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  → score                  Gemini 2.5 Flash, fit 0-100, batches of 10&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  → record                 Google Sheet, 27 columns&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  → scan Gmail             classify replies → update statuses&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  → tailor                 auto-tailor every job scoring ≥ 60&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  → digest                 one email: today&apos;s shortlist, ranked&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dedupe step matters more than it sounds: the same job shows up on LinkedIn, Adzuna, &lt;em&gt;and&lt;/em&gt; the company’s own board. JobPilot hashes company + title + location, keeps the highest-fidelity source, and I see it exactly once.&lt;/p&gt;
&lt;h2&gt;Built for the international-student reality&lt;/h2&gt;
&lt;p&gt;This is the part no job board does for you. Before any job even reaches the scorer, &lt;strong&gt;13 regex patterns&lt;/strong&gt; auto-reject the dead ends:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“US citizenship required” → gone&lt;/li&gt;
&lt;li&gt;”security clearance / TS-SCI / polygraph / ITAR” → gone&lt;/li&gt;
&lt;li&gt;”unable to provide visa sponsorship” / “cannot sponsor” → gone&lt;/li&gt;
&lt;li&gt;senior / staff / principal / 5+ years / contract / part-time → gone (I’m early-career; 0 to 2 years only)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then the scorer double-checks: if the description &lt;em&gt;hints&lt;/em&gt; sponsorship is unlikely, the fit score gets capped at 10 and the row is auto-rejected with a note. I never spend ten minutes reading a posting that was never going to work.&lt;/p&gt;
&lt;h2&gt;Four resumes, tailored while I sleep&lt;/h2&gt;
&lt;p&gt;I keep &lt;strong&gt;4 master resumes&lt;/strong&gt; as one-page LaTeX files: SDE, MLE, AI Engineer, and Forward-Deployed Engineer. The scorer picks which variant fits each job, then Gemini tailors it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automatic&lt;/strong&gt; for anything scoring ≥ 60 (capped at 15 per run).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One click&lt;/strong&gt; for everything else, a Tailor button in the dashboard.&lt;/li&gt;
&lt;li&gt;Output: a tailored one-page PDF &lt;strong&gt;plus a matching cover letter&lt;/strong&gt;, compiled with pdflatex, filed into Drive as &lt;code&gt;{company}_resume.pdf&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two guardrails I refuse to ship without. First, &lt;strong&gt;truth&lt;/strong&gt;: the model may reorder, rephrase, and re-emphasize, but it may never invent an employer, a date, a metric, or a skill. Second, &lt;strong&gt;format&lt;/strong&gt;: if the PDF spills past one page, it gets recut automatically, and an ATS check gates every resume at ≥ 85% keyword coverage.&lt;/p&gt;
&lt;p&gt;Cost per tailored resume + cover letter? &lt;strong&gt;About a penny.&lt;/strong&gt; Gemini 2.5 Flash is absurdly cheap for this.&lt;/p&gt;
&lt;h2&gt;The application pipeline, tracked&lt;/h2&gt;
&lt;p&gt;Every job moves through stages in the Sheet: &lt;strong&gt;New → Applied → Response → Interview → Offer&lt;/strong&gt; (or Rejected). The fun part is that the statuses update themselves: a Gmail scanner reads the last 3 days of replies, matches them to tracked applications, and classifies each one: rejection, interview invite, next steps.&lt;/p&gt;
&lt;p&gt;Two hard rules: the system &lt;strong&gt;never sends an email on its own&lt;/strong&gt; (outreach drafts land in my Gmail drafts, I hit send), and automation &lt;strong&gt;never overwrites my manual edits&lt;/strong&gt;, it only ever moves a status forward.&lt;/p&gt;
&lt;h2&gt;The dashboard&lt;/h2&gt;
&lt;p&gt;A Next.js console on Cloud Run (locked behind IAP, so only I can see it) sitting on top of a Google Sheet that serves as the entire database. Filter by fit, source, status, resume variant; click Tailor; read the “why” the scorer wrote for each match. Plus a morning digest email with the day’s shortlist ranked by fit. Most days I make my apply list from the email alone.&lt;/p&gt;
&lt;h2&gt;Tech stack &amp;amp; what it costs&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pipeline:&lt;/strong&gt; Python 3.12, httpx, pydantic, on a Cloud Run job&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Brain:&lt;/strong&gt; Gemini 2.5 Flash on Vertex AI (scoring + tailoring + reply classification)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resumes:&lt;/strong&gt; LaTeX + pdflatex in Docker&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dashboard:&lt;/strong&gt; Next.js 16 on Cloud Run, behind IAP&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database:&lt;/strong&gt; a Google Sheet (genuinely; it’s free, visual, and I can edit it from my phone)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Glue:&lt;/strong&gt; Cloud Scheduler, Secret Manager, GitHub Actions auto-deploy on push&lt;/li&gt;
&lt;/ul&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Cost&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Setup&lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cloud Run + Scheduler + Sheets + Drive&lt;/td&gt;&lt;td&gt;≈ $0 (free tiers)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Gemini 2.5 Flash (scoring ~hundreds of jobs + tailoring daily)&lt;/td&gt;&lt;td&gt;pennies&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Apify LinkedIn scraper&lt;/td&gt;&lt;td&gt;$0 to $5/mo (free credits)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&amp;lt; $5/month&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;By the numbers&lt;/h2&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;5 hours&lt;/div&gt;
    &lt;div&gt;idea → deployed on GCP&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;&amp;lt; $5/mo&lt;/div&gt;
    &lt;div&gt;to run the whole thing&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;~1¢&lt;/div&gt;
    &lt;div&gt;per tailored resume + cover letter&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;LinkedIn alone&lt;/th&gt;&lt;th&gt;JobPilot&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Job freshness&lt;/td&gt;&lt;td&gt;days to weeks old, reposts&lt;/td&gt;&lt;td&gt;hours old, straight from ATS boards&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Sponsorship dead ends&lt;/td&gt;&lt;td&gt;read them all yourself&lt;/td&gt;&lt;td&gt;auto-rejected before you see them&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Resume per application&lt;/td&gt;&lt;td&gt;one generic PDF&lt;/td&gt;&lt;td&gt;1 of 4 variants, tailored per job, ~1¢&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Tracking&lt;/td&gt;&lt;td&gt;a spreadsheet you forget to update&lt;/td&gt;&lt;td&gt;statuses update from your inbox&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Daily effort&lt;/td&gt;&lt;td&gt;hours of scrolling&lt;/td&gt;&lt;td&gt;read one digest email&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;One last detail: the hero image on this post was generated by Nano Banana inside the same GCP project the pipeline runs in. The job hunt is fully on-brand now.&lt;/p&gt;
&lt;p&gt;The realization that made me build this: &lt;strong&gt;a job search is a pipeline problem, not a willpower problem.&lt;/strong&gt; Recruiters have ATS systems, dashboards, and automation on their side of the table. There’s no rule that says applicants can’t have the same. Mine just had to cost less than a coffee.&lt;/p&gt;</content:encoded></item><item><title>How I trained Llama to argue more convincingly</title><link>https://sampreethavvari.github.io/posts/llama-rlhf/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/llama-rlhf/</guid><description>I trained Llama 3.1 to write better comebacks in online debates, using a reward model and RLHF. Here&apos;s what actually moved the needle, and what didn&apos;t.</description><pubDate>Wed, 10 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I worked on this with Prof. Marco Morucci’s group at NYU, and the question sounds simple: take a controversial claim, and train a model to write a comeback that real people find &lt;em&gt;more convincing&lt;/em&gt; than what the model already said.&lt;/p&gt;
&lt;p&gt;Simple to ask. Sneaky to pull off. Most people assume the magic is in which training algorithm you pick. It isn’t. By the time I got to the fancy algorithm choices, that part barely mattered. The thing that decided everything was much earlier and much more boring: where do you get good examples of “convincing,” and how do you stop the model from learning the wrong lesson?&lt;/p&gt;
&lt;h2&gt;Where do you find “convincing” arguments?&lt;/h2&gt;
&lt;p&gt;You need examples of arguments that actually changed someone’s mind. Turns out Reddit has a perfect spot for this: a community called &lt;strong&gt;ChangeMyView&lt;/strong&gt;, where someone posts an opinion and hands out a little award (a “delta”) to any reply that genuinely changes how they think.&lt;/p&gt;
&lt;p&gt;That award is gold. It’s a real human saying “okay, you convinced me.” So I mined a few years of these debates and built pairs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;the winner&lt;/strong&gt;: a reply that earned a “you changed my mind”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the loser&lt;/strong&gt;: a reply in the same thread that didn’t&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That gave me about &lt;strong&gt;38,000 clean pairs&lt;/strong&gt;. I split them carefully so the same debate never showed up in both training and testing, otherwise the model could just memorize instead of learn.&lt;/p&gt;
&lt;h2&gt;The plan, in plain English&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;38k &quot;winner vs loser&quot; pairs&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ Step 1: show Llama lots of winning replies&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │          (basically, &quot;write more like this&quot;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ├─ Step 2: train a JUDGE model&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │          (given two replies, which one won?)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        └─ Step 3: let Llama practice, and reward it&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                   whenever the judge likes its answer&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                ▼&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        Test it: real people, blind, pick the better reply&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three moves. First, teach Llama to imitate good arguments. Second, train a separate &lt;strong&gt;judge&lt;/strong&gt; model that can look at two replies and call the better one. Third, let Llama practice writing arguments and give it a thumbs-up whenever the judge approves. That last loop is the “RLHF” everyone talks about. It’s really just practice with a scorekeeper.&lt;/p&gt;
&lt;h2&gt;What broke (and what it taught me)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The judge fell for long-winded answers.&lt;/strong&gt; My first judge quietly decided that &lt;em&gt;longer&lt;/em&gt; meant &lt;em&gt;more convincing&lt;/em&gt;. So when Llama practiced against it, Llama learned to just… ramble. More words, higher score, worse arguments. The fix was to make sure my winner/loser pairs were about the same length, so the judge had to learn quality, not word count.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two versions of “the same” model didn’t match.&lt;/strong&gt; A couple of pieces had been built on slightly different starting points, and the scores drifted in weird ways for no obvious reason. The fix was dull and important: lock everything to the exact same starting model.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A naming clash ate one of my runs.&lt;/strong&gt; Two experiments grabbed the same name and one overwrote the other halfway through. I recovered from saved checkpoints and started giving every run its own unmistakable name. Lesson learned the annoying way.&lt;/p&gt;
&lt;h2&gt;The fancy-algorithm showdown that… didn’t matter much&lt;/h2&gt;
&lt;p&gt;There are two popular ways to run that “practice with a scorekeeper” loop: one’s called PPO, the other GRPO. People argue about them a lot. So I ran both from the exact same starting point, same judge, same everything, the cleanest comparison I could set up.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;~67%&lt;/div&gt;
    &lt;div&gt;of people preferred my trained model over the base&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;38k&lt;/div&gt;
    &lt;div&gt;real &quot;you changed my mind&quot; examples&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;~71%&lt;/div&gt;
    &lt;div&gt;how often the judge model picked the true winner&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The result? GRPO got there faster. PPO was a bit smoother. In a blind test where real people picked the better reply, both beat the original model about &lt;strong&gt;66 to 67%&lt;/strong&gt; of the time, and against each other it was basically a coin flip. The big, much-debated algorithm choice was the &lt;em&gt;least&lt;/em&gt; important decision in the whole project.&lt;/p&gt;
&lt;h2&gt;The takeaway&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;The judge model is the whole ballgame. Spend your time on the examples you feed it, not on the fancy training algorithm.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A great judge with a basic training loop beats a weak judge with the most exotic algorithm you can find, every time. GRPO is faster. It will not save you from a judge that secretly rewards rambling.&lt;/p&gt;
&lt;p&gt;And the part I’m actually proud of isn’t the 67%. It’s that I saw the “longer = better” trap coming &lt;em&gt;before&lt;/em&gt; it wasted a week of computing, because I stopped to think about how the judge could cheat. That’s the kind of engineer I want to be: the one who plans around the obvious mistake before it happens, not the one who explains it afterward.&lt;/p&gt;
&lt;p&gt;This was research with Prof. Marco Morucci’s group at NYU.&lt;/p&gt;</content:encoded></item><item><title>How I built the boring parts of an ML system on purpose</title><link>https://sampreethavvari.github.io/posts/loan-radar-mlops/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/loan-radar-mlops/</guid><description>A loan-scoring service where I built the unglamorous parts on purpose: training in containers, model history, quality gates, automatic retraining. The gates mattered most.</description><pubDate>Thu, 01 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The hardest part of ML in production is not the model. The hardest part is everything that happens after: how you know when to retrain, who approved the version running right now, what data it was trained on, and how fast you can swap it out when something goes wrong. Most projects skip those questions. They show up as the post-incident follow-up six months later.&lt;/p&gt;
&lt;p&gt;Loan Radar was a grad-school project built with that gap in mind. The domain is loan-default scoring, where the modeling problem is well-understood. I picked it precisely because it let me put all my time into the operational layer: lineage, gates, automated retraining, rollback paths. The stuff teams usually defer until the first fire.&lt;/p&gt;
&lt;h2&gt;The whole system in one diagram&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;Container: train.py --config configs/baseline.yaml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ▼&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;MLflow run = code commit + data hash + hyperparams + metrics + weights&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ▼ candidate&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;┌────────────────────────────────────────────────┐&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│  6 quality gates                               │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│  ── min AUC                                    │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│  ── calibration vs reference                   │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│  ── subgroup fairness                          │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│  ── inference latency p95 ≤ 50ms               │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│  ── output-distribution KL on canary           │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;│  ── serialised size cap                        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;└────────────────────────────────────────────────┘&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │  all green&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ▼&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Staging → Slack ack → Production (canary rollout)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Weekly Airflow DAG re-runs the whole loop against fresh data.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The six gates are the project&lt;/h2&gt;
&lt;p&gt;Every model run goes through the same six checks before it can be promoted. Any failure short-circuits the build:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;gate_01_min_auc            held-out AUC ≥ floor&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;gate_02_calibration        Brier score within tolerance&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;gate_03_subgroup_fairness  no subgroup with TPR delta &amp;gt; threshold&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;gate_04_inference_latency  containerised p95 ≤ 50ms on the harness&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;gate_05_drift_canary       output-distribution KL within bounds&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;gate_06_size               serialised model under size cap&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reason they’re scripts in git instead of checkboxes in a dashboard: it makes “did the model pass our quality bar” a deterministic, auditable thing instead of an opinion.&lt;/p&gt;
&lt;h2&gt;Honest latency numbers, not vibes&lt;/h2&gt;
&lt;p&gt;A benchmark harness runs against the production model on a single 2-CPU container. Results: &lt;strong&gt;0.79ms&lt;/strong&gt; median latency for a single caller, &lt;strong&gt;0.87ms&lt;/strong&gt; at p95, sustained throughput around &lt;strong&gt;33,000 samples/second&lt;/strong&gt; before p99 climbs.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;0.79 ms&lt;/div&gt;
    &lt;div&gt;median single-caller latency&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;~33k req/s&lt;/div&gt;
    &lt;div&gt;throughput before p99 climbs&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;6 gates&lt;/div&gt;
    &lt;div&gt;before any model can be promoted&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Numbers exist because the harness exists. Plenty of teams quote “tens of thousands of QPS” off back-of-envelope math. Running the load test is the difference.&lt;/p&gt;
&lt;h2&gt;Retraining is half automated, half human&lt;/h2&gt;
&lt;p&gt;The Airflow DAG runs weekly:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Pull the latest data manifest.&lt;/li&gt;
&lt;li&gt;Skip if manifest diff is below threshold.&lt;/li&gt;
&lt;li&gt;Train the container against the new manifest.&lt;/li&gt;
&lt;li&gt;Run the six gates.&lt;/li&gt;
&lt;li&gt;If green, promote to staging + Slack ack required.&lt;/li&gt;
&lt;li&gt;After human ack, canary roll to production.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Slack ack is deliberate. Fully automated rollouts on a financial model only make sense when rollback is trivially safe. A five-minute human pause is cheap compared to an automatic rollout that violates a calibration assumption nobody’d thought to gate on.&lt;/p&gt;
&lt;h2&gt;What this taught me&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;The boring parts are the parts. A 99% accurate model with no lineage and no gates is operationally worse than a 92% accurate model with both.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The Hybridge work I did later carries the same DNA. The &lt;a href=&quot;/posts/cbct-scan-validator&quot;&gt;CBCT validator&lt;/a&gt; has a gate before any promotion. The &lt;a href=&quot;/posts/clinical-rag&quot;&gt;consultation QA pipeline&lt;/a&gt; has schema-validated outputs and a single-retry contract. Both use a hot-swap deploy shape so retrains don’t need a rebuild. Both share a single ETL module across train and serve.&lt;/p&gt;
&lt;p&gt;Those patterns came out of Loan Radar.&lt;/p&gt;
&lt;p&gt;The kind of engineer I want to be is the one who asks “how do we roll this back” before asking “what accuracy did we hit.” Demos age out; gates are what survive contact with reality. Loan Radar was the project that taught me to build in that order.&lt;/p&gt;
&lt;p&gt;Live demo: &lt;a href=&quot;https://hi.switchy.io/cv-i&quot;&gt;Loan Radar&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>How I rebuilt a brittle call grader into a coaching platform</title><link>https://sampreethavvari.github.io/posts/npc-coach/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/npc-coach/</guid><description>A prototype scored every front-desk call out of 60 and emailed a link to a Google Doc. I rebuilt it into a coaching platform: a verbatim quote behind every score, patient safety as a loud flag, the math kept out of the model, and a dashboard a manager actually opens. Here is the whole story, including the bugs my tests could not catch.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The job of a New Patient Coordinator is the first phone call. Someone is nervous, maybe in pain, and the way that call goes decides whether they ever walk in. The practice founder, Dr. Frank LaMar, wrote a careful playbook for those calls. The question was how to coach forty people against it without a manager listening to hundreds of recordings a week.&lt;/p&gt;
&lt;p&gt;We did not start from zero. An earlier grader, built in a no-code tool called n8n, already pulled the call recordings off a server every hour and produced a report. It proved the idea was worth something. And then it hit a ceiling I could not raise without rebuilding the thing underneath it.&lt;/p&gt;
&lt;p&gt;So I rebuilt it. This is the whole story: what was wrong, how it grades a call now, the architecture decisions I would make again, the bugs my tests never caught, and the dashboard a manager actually lives in.&lt;/p&gt;
&lt;h2&gt;What the prototype did, and where it stopped&lt;/h2&gt;
&lt;p&gt;The old version graded each call as a flat sum out of 60, wrote the result as a blob of text into a single Google Doc, and emailed a link. That Doc was the only record.&lt;/p&gt;
&lt;p&gt;That design quietly broke in a lot of places at once.&lt;/p&gt;
&lt;div&gt;
&lt;strong&gt;What the prototype could not do.&lt;/strong&gt; No database, so no trends, no per-coordinator averages, and no way to answer the question a manager actually asks: how is this person doing this month? The score had no quotes behind it, so nobody could see why a call got a 47. It kept no record of which non-negotiables passed or failed. It shared the patient&apos;s name and the full transcript to the entire company. And if the workflow died halfway through, the call was skipped in silence.
&lt;/div&gt;
&lt;p&gt;I kept the same playbook and the same model family, and rebuilt everything around three things the prototype never had: evidence behind every score, patient safety as a first-class signal, and a real system of record you can query.&lt;/p&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;The prototype&lt;/th&gt;&lt;th&gt;What runs now&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Score&lt;/td&gt;&lt;td&gt;Flat sum out of 60&lt;/td&gt;&lt;td&gt;Weighted 40/40/20 out of 100, plus 3 hard gates and a patient-urgency override&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Evidence&lt;/td&gt;&lt;td&gt;A number with nothing behind it&lt;/td&gt;&lt;td&gt;A verbatim transcript quote behind every one of six criteria&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Output&lt;/td&gt;&lt;td&gt;Text parsed by position&lt;/td&gt;&lt;td&gt;Validated structured data, scored by code, not the model&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Record&lt;/td&gt;&lt;td&gt;One Google Doc per call&lt;/td&gt;&lt;td&gt;A queryable BigQuery store feeding a 26-endpoint dashboard&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Privacy&lt;/td&gt;&lt;td&gt;Full transcript and name shared company-wide&lt;/td&gt;&lt;td&gt;A dashboard locked to the practice domain behind sign-in&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Reliability&lt;/td&gt;&lt;td&gt;A mid-run failure dropped the call silently&lt;/td&gt;&lt;td&gt;Deterministic call IDs, so a call is never graded twice or lost&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;The one rule the whole thing is built on&lt;/h2&gt;
&lt;p&gt;The principle under all of it comes straight from the playbook: the coordinator is the patient’s first advocate, not a clinician and not a salesperson. The system grades judgment and patient safety. It never grades booking rate.&lt;/p&gt;
&lt;p&gt;That sounds soft. It is actually the hardest constraint in the build. A call where the patient politely defers is not a failure. A patient who books an appointment just to get off the phone is a failure of advocacy, not a win. Correctness beats conversion, every time, and I had to write the grader so it can never quietly reward the reverse.&lt;/p&gt;
&lt;h2&gt;What runs now, end to end&lt;/h2&gt;
&lt;p&gt;A call happens. The phone system drops the recording on a server, or our other source hands an inbound call back with a transcript already attached. From there the pipeline does six things.&lt;/p&gt;
&lt;div&gt;
&lt;strong&gt;The pipeline.&lt;/strong&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Ingest&lt;/strong&gt; the day&apos;s recordings, or accept the live webhook. Each call gets a deterministic ID so it can never become two records.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transcribe&lt;/strong&gt;. Outbound recordings go through Vertex Gemini. Inbound calls reuse the transcript that already exists, which keeps that cost near zero.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Classify&lt;/strong&gt; what the call actually is. A new-patient conversation goes forward. A voicemail, a wrong number, or an existing patient gets tagged and set aside.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Score&lt;/strong&gt; the relevant calls against the playbook, criterion by criterion, with a quote behind every judgment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resolve identity&lt;/strong&gt; so every call to and from the same person links to one stable patient ID.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Report and store&lt;/strong&gt;. The verdict lands in BigQuery, the transcript and report in Cloud Storage, and the dashboard renders both.&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;h2&gt;How it grades a call&lt;/h2&gt;
&lt;p&gt;People ask about the score first, so here is the real math. Every relevant call is graded on six criteria, each from 0 to 10.&lt;/p&gt;
&lt;div&gt;
&lt;strong&gt;The six criteria.&lt;/strong&gt;
C1, role identification and advocacy. C2, patient identification and referral framing. C3, rapport and emotional connection. C4, classification before conversion (pin down the scope before pushing an appointment). C5, sequencing and scheduling logic (the right appointment, not the fastest one). C6, use of differentiators, only when a patient concern actually calls for one. Most calls do not, and the absence is never penalized.
&lt;/div&gt;
&lt;p&gt;Those six roll into three weighted buckets. The headline is a plain weighted sum, nothing fancier.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;final_score = 10 × ( 0.40 × mean(C1, C3)    # rapport&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                   + 0.40 × mean(C2, C4)    # classification&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                   + 0.20 × mean(C5, C6) )  # scheduling&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That maps to four bands: Gold at 85 and up, Strong at 70 to 84, Developing at 50 to 69, Fail below 50. On top of the number sit two safety layers that have nothing to do with how nice the call was: three hard gates (did the coordinator identify their role, capture the patient’s name, and frame the referral correctly) and a patient-urgency override (if someone is in real pain and the coordinator delays intake or pivots to financing, that is recorded as a safety failure).&lt;/p&gt;
&lt;h3&gt;The decision I reversed&lt;/h3&gt;
&lt;p&gt;The first version of the rubric capped any gate-failing call at 49 and subtracted points for a safety miss. It looked principled. Miss a non-negotiable, crater the score.&lt;/p&gt;
&lt;p&gt;In practice that cap destroyed information. A genuinely warm, well-sequenced call that happened to use one bit of retail framing would collapse to 49, which told a coach nothing about everything else the coordinator did well. So I removed the cap entirely. The score became the pure weighted sum, and the gate failure became a loud red badge next to it.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;a flag, not a cap&lt;/div&gt;
  &lt;div&gt;A call can now read 84 and still be flagged as failed. The coach sees both at once: how good the call was, and which non-negotiable was missed. The number and the safety flag stopped fighting each other.&lt;/div&gt;
&lt;/div&gt;
&lt;h3&gt;Why I took the math away from the model&lt;/h3&gt;
&lt;p&gt;This is the part I would defend hardest. The model produces only three things: the six integer scores, the gate booleans, and the urgency state. A pure Python function turns those into the final number.&lt;/p&gt;
&lt;p&gt;Keeping the arithmetic out of the model means the headline score is reproducible. Same inputs, same number, every time. And if the practice decides scheduling should count for 30% instead of 20%, that is a one-line change to a function, not a prompt rewrite and a re-check of every past call. The model does the part it is good at, reading a conversation and judging it with a quote to back it up, and none of the part it is bad at, consistent arithmetic.&lt;/p&gt;
&lt;h2&gt;The architecture I would do again&lt;/h2&gt;
&lt;p&gt;A few design choices shaped everything else. None of them are clever. They are boring on purpose, which is exactly why they held up when real patient data started flowing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ports and adapters, so the cloud is swappable.&lt;/strong&gt; Every external dependency (storage, the transcriber, the scorer, the classifier, the patient registry, delivery) sits behind a port. The real Google Cloud services are wired in at a single place. In tests, the same ports get in-memory fakes.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;safe by default&lt;/div&gt;
  &lt;div&gt;One flag, NPC_BAA_ACCEPTED, gates every path that touches real patient data. When it is off, the app wires in fakes and touches nothing real. That is the safe default for demos and local work, and it is why 1,156 tests run with no network and no cloud credentials.&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;That one flag came out of a compliance fact that bites a lot of healthcare projects: a Google Workspace agreement does not cover Google Cloud services. Cloud Storage, Vertex AI, and BigQuery need a separate Cloud agreement, and nothing real is allowed to flow until that is signed. I made that a single line of config instead of a rule someone has to remember.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cost discipline through structural dedup.&lt;/strong&gt; Inbound calls already arrive with a transcript, so we never pay to transcribe them twice. At the practice’s volume, that is the single biggest reason the cloud bill lands around 50 to 250 dollars a month instead of several times that. The cheapest API call is the one you never have to make.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ship the smaller correct thing first.&lt;/strong&gt; The full design is an event-driven mesh with queues between every stage. For the first real run I deliberately did not build all of that. I wired a direct, single-process pipeline so the dashboard could light up on real calls while the heavier plumbing waited its turn. That choice paid off twice: real calls got scored early, and the whole delivery layer the coordinators actually feel (emailed reports, inline recordings, a daily summary) landed on that simple pipeline instead of blocking on the full mesh.&lt;/p&gt;
&lt;h2&gt;Four bugs my test suite could not catch&lt;/h2&gt;
&lt;p&gt;The polished version of a project hides exactly the parts worth writing down. Every one of these passed a green test suite and still went wrong against reality.&lt;/p&gt;
&lt;div&gt;
&lt;strong&gt;The wheel that passed a thousand tests and still failed to deploy.&lt;/strong&gt; The first deploy failed at build time even with the whole suite green. A packaging setting tried to add the same asset files to the image twice. The tests never saw it because they use an editable install that never builds a real package. The Docker image does a real install, so it tripped on the first deploy. A green suite proves the code runs, not that it packages.
&lt;/div&gt;
&lt;div&gt;
&lt;strong&gt;Forty percent of real calls came back &quot;not gradeable.&quot;&lt;/strong&gt; The output schema required an exact transcript quote for any non-zero score, and on a garbled transcript the model could not always find an exact substring, so a perfectly reasonable score got rejected on a technicality. I loosened the quote requirement from mandatory to advisory, made the remaining failures report why instead of failing silently, and added a relevance gate so calls that should never have reached the scorer get filtered earlier.
&lt;/div&gt;
&lt;div&gt;
&lt;strong&gt;Reruns that forked a call&apos;s history.&lt;/strong&gt; A rerun could create a fresh record instead of updating the original, splitting one call across two IDs. I scoped the claim to the original call ID, so a rerun reattaches to the same call rather than minting a new one. One call, one record, no matter how many times it is reprocessed.
&lt;/div&gt;
&lt;div&gt;
&lt;strong&gt;A transcriber that misheard the practice name a dozen ways.&lt;/strong&gt; It rendered &quot;Hybridge&quot; as &quot;High Bridge,&quot; &quot;Amadana,&quot; &quot;Hyperjet Omaha,&quot; and more. There is no Omaha office. Re-transcribing costs money, so I edit the stored text in place for known mishearings, and feed the real practice names into the transcription prompt going forward so the model has the right vocabulary up front.
&lt;/div&gt;
&lt;div&gt;
  &lt;div&gt;diagnosable beats silent&lt;/div&gt;
  &lt;div&gt;A call that fails grading and tells you why is a to-do item. A call that fails silently is a hole in your data you find weeks later, if ever.&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;The dashboard a manager actually opens&lt;/h2&gt;
&lt;p&gt;The pipeline grades the calls. The dashboard is the surface a coordinator and a manager live in. It is a single-page React app behind Google sign-in, backed by 26 endpoints, with one mantra pinned in the sidebar the whole time: correctness over conversion, no patient data in logs.&lt;/p&gt;
&lt;p&gt;The heart of it is the call detail view. It opens with a live pipeline stepper (Ingest, Transcribe, Tag, Score, Report) that refreshes while a call is still moving. Then the coaching report itself.&lt;/p&gt;
&lt;div&gt;
&lt;strong&gt;What a coach sees on one call.&lt;/strong&gt; A red banner if a hard gate failed, naming which one, and stating plainly that the score is still calculated normally because the gate is a flag, not a cap. A score-breakdown panel showing the three buckets as bars, then the weighted sum and final number, so the math is on the screen instead of hidden in the model. Each of the six criteria with its score, the model&apos;s reasoning, the exact transcript quote it cited, and a one-line coaching note. The recording plays inline, right next to the report.
&lt;/div&gt;
&lt;div&gt;
  &lt;div&gt;every judgment, a quote&lt;/div&gt;
  &lt;div&gt;A coach does not just see the number. They see the exact moment in the transcript that earned it, so the coaching is grounded in something the coordinator already remembers.&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The surprise feature is Settings. The scoring rubric is not buried in the source. It is editable in the browser, and it is the same rubric the scorer runs. A manager can tune the playbook, save a new immutable version with a change note, and roll back without losing history, all without opening a code editor or pinging me.&lt;/p&gt;
&lt;p&gt;And a lot of the best features were never in the spec. They came from looking at real output: a voicemail tag that pulls voicemails off the grader, an uncooperative-caller flag so an unfair call does not drag down an average, the relevance gate that came out of the “not gradeable” hunt, and a daily 5pm summary where Gemini rolls the day’s calls into one digest for the manager.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;On one real day, the system ingested about 285 recordings, found and scored 40 real new-patient calls with quoted evidence, auto-filtered 3 voicemails, and flagged 28 calls for missing a non-negotiable.&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Calendar time, first to latest commit&lt;/td&gt;&lt;td&gt;18 days&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Python source&lt;/td&gt;&lt;td&gt;~20,800 lines across 20 modules&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Tests&lt;/td&gt;&lt;td&gt;1,156, all on synthetic data, no cloud&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;API endpoints&lt;/td&gt;&lt;td&gt;26&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Scoring&lt;/td&gt;&lt;td&gt;6 criteria, 3 hard gates, 40/40/20 weighting, 4 bands&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Target volume and cost&lt;/td&gt;&lt;td&gt;1,000 to 5,000 calls a month, about 50 to 250 dollars&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The prototype told you a call scored 47 out of 60. This tells you the call scored 84, names the exact moment the coordinator missed a gate, quotes what they said, and puts that next to their own 90-day average. That is the difference between a number and a coaching tool.&lt;/p&gt;
&lt;p&gt;If there is one lesson under all of it: a green test suite is necessary, not sufficient. It proved the logic ran. It did not catch the packaging break, and it did not tell me 40% of real calls would not grade. The real calls did. Ship the smaller correct thing, watch what real data does to it, and let that drive the next feature, not the original plan.&lt;/p&gt;</content:encoded></item><item><title>How I caught my pipeline lying to me (and made it confess)</title><link>https://sampreethavvari.github.io/posts/pipeline-ghosting/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/pipeline-ghosting/</guid><description>Runs showed ✅ done while emails never sent, rows never appeared, and whole meetings just vanished. Nine kinds of silent failure, all hidden. The fix was making the dashboard stop lying.</description><pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A while back I wrote about &lt;a href=&quot;/posts/clinical-rag&quot;&gt;the pipeline that grades every implant consult&lt;/a&gt; against our CEO’s seven-criterion rubric. Zoom drops a transcript, Gemini scores it, an email goes to the doctor + treatment coordinator + CEO, and a row appends to a master Sheet. Beautiful on paper.&lt;/p&gt;
&lt;p&gt;In practice it was a polite liar. Runs would proudly show &lt;span&gt;✅&lt;/span&gt; &lt;strong&gt;complete&lt;/strong&gt; while the email never went out, the Sheet never got the row, and half my coordinators’ meetings just… weren’t there. I’d been manually rerunning consults for two weeks and didn’t know why, while the CEO was actively testing in prod. Cool cool cool.&lt;/p&gt;
&lt;p&gt;The task: stop the lying. Every failure visible, every transient error retry-able, every state in sync, and the logs readable by humans who don’t write Python.&lt;/p&gt;
&lt;h2&gt;Nine flavors of silent failure, stacked like a sad lasagna&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The missing-host mystery.&lt;/strong&gt; Two coordinators were migrated to &lt;code&gt;@hybridgeimplants.com&lt;/code&gt; mailboxes; the allowlist only knew their old &lt;code&gt;@elmwooddental.com&lt;/code&gt; ones. Every meeting they hosted just quietly &lt;code&gt;return&lt;/code&gt;-ed. Now multi-email TCs live in &lt;code&gt;host_mapping.yaml&lt;/code&gt;, a YAML edit, no Secret Manager dance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The rerun ghost button.&lt;/strong&gt; &lt;code&gt;RerunBody.send_email: bool = False&lt;/code&gt;. Click &lt;em&gt;Rerun&lt;/em&gt;, get a green checkmark, no email lands. Flipped the default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The bare-except graveyard.&lt;/strong&gt; Gmail, Sheets, and OAuth blips all swallowed while the run marched on to mark itself “complete.” Each one now surfaces as amber &lt;strong&gt;needs attention&lt;/strong&gt; with a plain-English line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The yellow that wasn’t there.&lt;/strong&gt; One tab painted &lt;em&gt;running&lt;/em&gt; yellow, another painted &lt;em&gt;failed&lt;/em&gt; yellow. Same run, two meanings. I unified the legend: &lt;span&gt;🟢&lt;/span&gt; done · &lt;span&gt;🟠&lt;/span&gt; needs attention · &lt;span&gt;🔴&lt;/span&gt; failed · &lt;span&gt;🔵&lt;/span&gt; running.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The retry that wasn’t.&lt;/strong&gt; A single 429 or broken pipe was a silent kill. Added exponential backoff with jitter for transient errors (more below).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The rerun-creates-duplicates problem.&lt;/strong&gt; A successful rerun left two rows for one consultation. Built &lt;em&gt;rerun-replaces-old&lt;/em&gt;: successful reruns delete the original and dedupe the Sheet; failed reruns leave the known-good baseline alone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The disappearing runs.&lt;/strong&gt; Events that died at the host gate or download step, before scoring, left zero trace. Moved dashboard row creation to the very top of the webhook handler, so every event leaves a clickable row even if it died at step zero.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The “logs are in Cloud Logging” wall&lt;/strong&gt; and &lt;strong&gt;9. the orphan-state drift&lt;/strong&gt;, both below.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Make the failures retry themselves&lt;/h2&gt;
&lt;p&gt;The connection-level errors were the sneaky ones. &lt;code&gt;HttpError&lt;/code&gt; covers the 429/5xx case Google’s client knows how to raise, but a &lt;code&gt;BrokenPipeError&lt;/code&gt; or &lt;code&gt;ConnectionResetError&lt;/code&gt; comes up from the socket layer and never wears an &lt;code&gt;HttpError&lt;/code&gt; jacket, so a decorator that only caught &lt;code&gt;HttpError&lt;/code&gt; waved them straight through to a silent kill. The fix: retry on all three families, with jittered backoff so a herd of reruns doesn’t synchronize into the next rate-limit wall.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;RETRYABLE&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; (HttpError, RefreshError, &lt;/span&gt;&lt;span&gt;BrokenPipeError&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;             ConnectionResetError&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;TimeoutError&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; attempt &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; range&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;MAX_RETRIES&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    try&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        return&lt;/span&gt;&lt;span&gt; fn()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    except&lt;/span&gt;&lt;span&gt; RETRYABLE&lt;/span&gt;&lt;span&gt; as&lt;/span&gt;&lt;span&gt; e:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        if&lt;/span&gt;&lt;span&gt; attempt &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; MAX_RETRIES&lt;/span&gt;&lt;span&gt; -&lt;/span&gt;&lt;span&gt; 1&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            raise&lt;/span&gt;&lt;span&gt;            # loud: surfaces as amber, never swallowed&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        sleep(&lt;/span&gt;&lt;span&gt;BASE&lt;/span&gt;&lt;span&gt; *&lt;/span&gt;&lt;span&gt; 2&lt;/span&gt;&lt;span&gt;**&lt;/span&gt;&lt;span&gt;attempt &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; jitter())&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A transient blip is no longer a death sentence, and a permanent failure still raises loudly instead of being marked complete.&lt;/p&gt;
&lt;h2&gt;Logs humans can read, state that can’t lie&lt;/h2&gt;
&lt;p&gt;The biggest cultural fix was killing “the logs are in Cloud Logging” as an answer. Every run detail page now has a &lt;strong&gt;Logs tab&lt;/strong&gt; with two views: an &lt;strong&gt;Easy view&lt;/strong&gt;, the nine stages as a stepper (&lt;span&gt;✅&lt;/span&gt; &lt;span&gt;⚠️&lt;/span&gt; &lt;span&gt;❌&lt;/span&gt; ⏭ &lt;span&gt;🔄&lt;/span&gt; ⏸), each non-passed stage carrying a plain-English line plus &lt;em&gt;what to do&lt;/em&gt;, and a &lt;strong&gt;Developer view&lt;/strong&gt; with BigQuery audit events, tracebacks, and the raw Firestore doc. A non-technical operator can finally tell what failed without learning what a &lt;code&gt;RefreshError&lt;/code&gt; is.&lt;/p&gt;
&lt;p&gt;Behind it runs a consistency reconciler after every status change: if the doc says “Sheet appended” but the row isn’t there → flip to amber; if the row &lt;em&gt;is&lt;/em&gt; there but the flag says no → flip to green. The dashboard mirrors reality instead of trusting a flag set optimistically before the work finished.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;0 drift&lt;/div&gt;
  &lt;div&gt;Doc state and real-world state reconcile after every status change, so the flag can&apos;t lie about what actually happened&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;A sprinkle of one-off cleanup scripts repaired the existing damage: resent 13 silently-failed emails (no duplicates), backfilled 4 missing Sheet rows, deduped one stray row, and reconciled one stubborn consultation that had lost its scorecard to a broken pipe.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;51 / 0 / 0&lt;/div&gt;
    &lt;div&gt;green / amber / red, with 0 drift and 0 duplicates in the audit&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;234&lt;/div&gt;
    &lt;div&gt;tests green, +50 of them covering the exact silent-fail paths&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;7 → 1&lt;/div&gt;
    &lt;div&gt;production deploys shipped in a single push&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The best part: the next time something breaks (and something always breaks) I’ll know in five seconds, because the dashboard isn’t allowed to lie to me anymore.&lt;/p&gt;
&lt;p&gt;The lesson, free of charge: &lt;strong&gt;&lt;code&gt;bare except&lt;/code&gt; is technical debt accruing interest.&lt;/strong&gt; Every silent failure I caught had been swallowed by one. Make your code loud when things go wrong. Your future self will thank you, and your CEO will stop emailing at 11pm asking “why didn’t I get the report?”&lt;/p&gt;</content:encoded></item><item><title>Reconciling a dental day without opening the spreadsheets</title><link>https://sampreethavvari.github.io/posts/reconciliation/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/reconciliation/</guid><description>Two front-desk staff spent about three hours each every day cross-checking three exports by hand to confirm every patient seen got documented and billed. I built a deterministic engine that does the cross-check and shows them only the flagged patients. Shipped, live, and HIPAA-safe by construction.</description><pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every day, each location in the group sees roughly 40 patients. For the books to be right and the chart to be defensible, every one of those encounters has to line up across three separate documents: the schedule (who was supposed to be seen), the production ledger (what got charged), and the clinical notes (what got documented). When the three agree, there is nothing to do. The whole job is the disagreements.&lt;/p&gt;
&lt;p&gt;Two front-office staff were finding those disagreements by hand, eyeballing three spreadsheets side by side. It took each of them about three hours a day, every day, forever. And because it was manual, it failed exactly where mistakes are most expensive: a patient treated and documented but never billed (lost revenue), or billed with no clinical note behind it (a compliance liability).&lt;/p&gt;
&lt;p&gt;I built Reconciliation to do the cross-check automatically and hand the staff only the handful of flagged patients instead of the whole schedule. It is shipped and live (v1.6.0) on Cloud Run, it reads two completely different practice-management systems through one engine, and no patient data has ever touched the repository.&lt;/p&gt;
&lt;h2&gt;Two systems, same underlying reality&lt;/h2&gt;
&lt;p&gt;The first complication is that the two practices run different software, and the exports look nothing alike on disk.&lt;/p&gt;
&lt;p&gt;The general practice runs Eaglesoft: legacy exports, binary &lt;code&gt;.xls&lt;/code&gt; then CSV, Windows &lt;code&gt;cp1252&lt;/code&gt; encoding, and duplicate column headers (three different &lt;code&gt;start_time&lt;/code&gt; columns in one file). The implant centers run Denticon: &lt;code&gt;.xlsx&lt;/code&gt; with title and footer junk rows, merged cells, a header that is not on the first row, and a column name literally spelled &lt;code&gt;OIffice Name&lt;/code&gt;. Same problem, two hostile file shapes.&lt;/p&gt;
&lt;p&gt;The second complication is HIPAA. Every one of those files is full of protected health information, so anything I built had to treat patient data as radioactive: never logged carelessly, never committed, never sent anywhere it should not go.&lt;/p&gt;
&lt;h2&gt;A deterministic engine, then everything else wraps it&lt;/h2&gt;
&lt;p&gt;The single most important decision was to split the system into a pure reconciliation engine and a thin application shell around it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;engine/   pure Python. No network, no database, no clock, no UI.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          Three files in, one structured auditable result out.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;app/      FastAPI. Auth, upload, dashboard, history, email, and the&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          in-progress AI clinical matcher. All of it wraps engine.reconcile(...).&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The engine is the part that has to be correct, so I kept it free of side effects. No I/O, no randomness, no reading the wall clock. It even takes the “generated at” timestamp as a parameter instead of calling &lt;code&gt;now()&lt;/code&gt;, so identical inputs produce byte-identical output. That is what makes the result auditable: same three files, same answer, every count traceable back to specific source rows. The messy real-world concerns (sessions, OAuth, uploads, email) all live in the shell, where they can be messy without threatening correctness.&lt;/p&gt;
&lt;h2&gt;Parsing: map columns by name, and fail loud&lt;/h2&gt;
&lt;p&gt;The parsers follow two rules. Map columns by name rather than position, because positions drift between exports and names are more stable, and when a name is duplicated, pick the right occurrence deterministically. And give each system its own adapter that validates the columns it needs and emits clean typed records. A missing required column is a loud, specific error, never a silently wrong answer. In a tool people use to sign off on money and compliance, “wrong but confident” is the worst possible failure.&lt;/p&gt;
&lt;p&gt;Both adapters emit the same typed models, so once parsed, Eaglesoft and Denticon records are indistinguishable to the rest of the system. A small &lt;code&gt;SOURCES&lt;/code&gt; registry dispatches to the right adapter based on a &lt;code&gt;source&lt;/code&gt; argument, which means adding a third practice-management system later is one new adapter, not a rewrite of the matching logic.&lt;/p&gt;
&lt;h2&gt;Matching: patient_id is the spine&lt;/h2&gt;
&lt;p&gt;To compare three files you need a reliable join key. The schedule and ledger both carry a clean &lt;code&gt;patient_id&lt;/code&gt;. The Eaglesoft notes file has no ID column at all. What it does have is a &lt;code&gt;sort_name&lt;/code&gt; like &lt;code&gt;ROTHFUSS, JAMES E15612&lt;/code&gt;, with the patient ID hiding as trailing digits on the name string, so the matcher recovers the key from there. I confirmed that was 100% extractable across a real notes export before relying on it.&lt;/p&gt;
&lt;p&gt;Keying on the ID rather than the name means two patients with the same name never collide. Names are only a fallback: when an ID cannot be lined up, the system attempts a normalized last-first match and tags the patient as low-confidence, needs human review. It never silently guesses. “I am not sure, look at this one” is always a safe answer.&lt;/p&gt;
&lt;h2&gt;Classification: a small set of honest buckets&lt;/h2&gt;
&lt;p&gt;For each scheduled patient, the engine computes two facts: &lt;code&gt;has_money&lt;/code&gt; (at least one real production charge, explicitly excluding credits, adjustments, and courtesy codes) and &lt;code&gt;has_note&lt;/code&gt; (at least one non-deleted clinical note). Those two booleans produce the verdict.&lt;/p&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Bucket&lt;/th&gt;&lt;th&gt;Condition&lt;/th&gt;&lt;th&gt;What it means&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;verified&lt;/td&gt;&lt;td&gt;money and note&lt;/td&gt;&lt;td&gt;Done and documented, no review needed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;note_no_money&lt;/td&gt;&lt;td&gt;note, no production&lt;/td&gt;&lt;td&gt;Documented but nothing posted (lost revenue)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;money_no_note&lt;/td&gt;&lt;td&gt;production, no note&lt;/td&gt;&lt;td&gt;Posted but undocumented (compliance risk)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;neither&lt;/td&gt;&lt;td&gt;no money, no note&lt;/td&gt;&lt;td&gt;No-show or missed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;review_non_scheduled&lt;/td&gt;&lt;td&gt;production for someone not on the schedule&lt;/td&gt;&lt;td&gt;Walk-in or mis-post&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;review_low_confidence&lt;/td&gt;&lt;td&gt;matched only by fuzzy name&lt;/td&gt;&lt;td&gt;Needs a human to confirm&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The green &lt;code&gt;verified&lt;/code&gt; bucket is the whole point. On a representative day of about 41 scheduled patients, roughly 30 land there automatically and disappear from the staff’s workload, leaving about a dozen exceptions to actually look at. Every record carries the evidence that placed it in its bucket (the specific ledger and note rows), a plain-English reason, and the input files’ SHA-256 hashes and row counts for audit.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;fail loud, never guess&lt;/div&gt;
  &lt;div&gt;A missing column errors out. An uncertain match gets flagged for a human. In a financial and clinical tool, a confident wrong answer is far worse than an honest &quot;look at this one.&quot;&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;HIPAA-safe testing, with no compromise&lt;/h2&gt;
&lt;p&gt;This is the constraint that shaped the whole test strategy, and I am happy with where it landed. No real patient data lives in the repo: &lt;code&gt;.gitignore&lt;/code&gt; blocks every &lt;code&gt;.xls&lt;/code&gt;, &lt;code&gt;.xlsx&lt;/code&gt;, &lt;code&gt;.csv&lt;/code&gt;, and &lt;code&gt;.pdf&lt;/code&gt;, and real exports stay on local machines only. The 150+ tests run against synthetic fixtures generated at runtime that reproduce every real-world quirk (duplicate headers, the trailing-ID &lt;code&gt;sort_name&lt;/code&gt;, deleted notes, credits, multi-appointment patients, non-scheduled production, fuzzy-name-only patients) without a single byte of PHI, so CI runs entirely clean. A golden test does run against a real day’s files, but it is opt-in, gated behind a &lt;code&gt;REAL_DATA_DIR&lt;/code&gt; variable, and skipped automatically when that is absent. The acceptance numbers from that real day became the literal definition of done.&lt;/p&gt;
&lt;h2&gt;The shell: auth, dashboard, history, email&lt;/h2&gt;
&lt;p&gt;Around the engine sits a FastAPI app on Cloud Run, with Firestore holding users, recipients, saved reports, and config. Google sign-in is restricted to the practices’ own domains plus an admin-managed allowlist, with two roles and self-serve user management so nobody files a ticket to add a colleague. The dashboard has a source tab, then upload three files, reconcile, and read color-coded summary cards; click any card and a popup shows every patient’s schedule, ledger, and note rows down to tooth, surface, provider, dollar amount, full note text, and the original spreadsheet row numbers. Every run is saved and searchable, with a reversible mark-as-fixed so a staff member can tick off a flagged patient once they have resolved it. Email needs no IT involvement: an admin clicks Connect Gmail once, and the app sends reports as that user through a stored refresh token.&lt;/p&gt;
&lt;h2&gt;The next frontier, built but not switched on&lt;/h2&gt;
&lt;p&gt;The current verdict is structural: production exists and a note exists, so the patient is verified. The real prize is clinical matching, confirming that the specific procedures billed are the specific procedures documented, tooth by tooth. I have built that layer on Gemini through Vertex AI, matching ledger procedures against note text by tooth, surface, description, and procedure code with a curated clinical-terminology dictionary (a “Cement Crown” charge maps to a “Delivered Crown” note, surface-letter counts have to match the procedure code, X-ray and tray codes are normalized, billing and admin lines are ignored).&lt;/p&gt;
&lt;p&gt;It is deliberately not wired into the live verdict yet. Clinical matching is high stakes, and the two practices document differently (per-procedure at the general practice, long consult narratives at the implant centers). Turning it on is gated on finalizing the terminology lists with each practice’s billing lead, so the engine asks instead of guesses. The plan is a preview mode that shows the AI’s read alongside the official structural verdict before it is ever allowed to change a count.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Shipped and live at v1.6.0, in daily use. About six person-hours of manual cross-checking became a few minutes of exception review. Lost-revenue and compliance gaps are caught structurally every day instead of depending on a tired human noticing a discrepancy across three windows. Two practice-management systems flow through one engine and one dashboard. The output is deterministic and auditable, the repository has never held a byte of PHI, and the AI clinical matcher is ready and waiting behind a conservative rollout rather than rushed into a place where it could quietly change the books.&lt;/p&gt;</content:encoded></item><item><title>How I built a recommender for 22 million records</title><link>https://sampreethavvari.github.io/posts/recsys-spark-bigdata/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/recsys-spark-bigdata/</guid><description>I built a recommendation system that handles 22 million records: one fast step to narrow the field, one smart step to rank it. It beat &apos;just show what&apos;s popular&apos; by 20%.</description><pubDate>Sat, 18 May 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most recommendation tutorials hand you a tiny dataset and one neat algorithm, and everything fits in your laptop’s memory. That teaches you the math and none of the real problem.&lt;/p&gt;
&lt;p&gt;The real problem shows up the moment you have &lt;strong&gt;22 million records&lt;/strong&gt;. Now you can’t just compare every person to every product, that’s trillions of comparisons, and your computer quietly bursts into flames. The question stops being “which fancy algorithm?” and becomes “how do I avoid doing work I don’t need to do?”&lt;/p&gt;
&lt;p&gt;That question has a well-known answer, and building it was the whole point of this project.&lt;/p&gt;
&lt;h2&gt;Why you can’t do it in one shot&lt;/h2&gt;
&lt;p&gt;If you try to score every user against every item, the numbers explode instantly. So real recommenders split the job into two steps, like a hiring funnel:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;22 million records&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ▼  Step 1: Shortlist (fast and rough)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  Group similar things together,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  only compare within a group&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │  a few hundred candidates per person, not millions&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ▼  Step 2: Rank (slow and smart)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  Score just that shortlist properly&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        │&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        ▼&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  Top picks for each person&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 1 is the bouncer.&lt;/strong&gt; Its only job is to throw out the obviously-irrelevant stuff fast. I used a trick that groups similar users and items into the same “buckets,” so each person only ever gets compared to a few hundred likely matches instead of millions. It’s a little sloppy on purpose, speed matters more than perfection here.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2 is the judge.&lt;/strong&gt; Now that the list is short, I could afford the smarter, slower math (the part that actually learns personal taste) and run it only on the few hundred survivors. Cheap, because the bouncer already did the heavy filtering.&lt;/p&gt;
&lt;h2&gt;Spreading the work across many computers&lt;/h2&gt;
&lt;p&gt;Even the shortlist step is too big for one machine at 22 million records. So the whole thing ran on &lt;strong&gt;Spark&lt;/strong&gt;, which is basically a way to split a giant job across a cluster of computers and have them all chip away at once.&lt;/p&gt;
&lt;p&gt;Getting that split right, who does what, when the machines have to talk to each other, is honestly most of the real engineering. The algorithm is the easy part to write. Making it run across a cluster without choking is the hard part.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;22M+&lt;/div&gt;
    &lt;div&gt;records, split across many machines at once&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;+20%&lt;/div&gt;
    &lt;div&gt;better than just recommending what&apos;s trending&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;100s&lt;/div&gt;
    &lt;div&gt;items ranked per person, not millions&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The number I’m proud of is &lt;strong&gt;+20% better than just showing what’s popular&lt;/strong&gt;. Beating random guessing is easy. Beating “just recommend whatever’s trending” is the honest bar, because that’s the lazy option that’s secretly pretty good. Clearing it by 20% means the personalization actually earned its keep.&lt;/p&gt;
&lt;h2&gt;What broke (and what it taught me)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A few mega-popular items jammed everything.&lt;/strong&gt; When you split work across machines, a handful of super-popular items can dump a giant pile on one poor machine while the others sit idle, and the whole job crawls. The fix is to spread those hot items out deliberately. Nobody warns you about this in the tutorials, because tutorials never have a mega-popular item.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The “shortlist” step is a dial, not a setting.&lt;/strong&gt; Make the buckets too tight and good matches fall out; too loose and the bouncer stops saving you any work. I had to tune it on purpose, treating it like the trade-off it is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;New users broke the smart step.&lt;/strong&gt; The ranking math has nothing to say about someone with zero history. So for brand-new users, I just fell back to “show them what’s popular”, which is also why having that popularity baseline built in from day one paid off twice.&lt;/p&gt;
&lt;h2&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;The lesson here isn’t about the dataset. It’s that the two-step shape, shortlist fast, then rank smart, is exactly how the big recommendation teams (think the “you might also like” on any huge site) keep things running. I built the small version of the real thing.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;When the data is huge, the model is only half the system. The other half is refusing to compute things you don’t actually need.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That stuck with me. Faced with 22 million rows, the instinct shouldn’t be “find a fancier algorithm.” It should be “what work can I avoid?” That’s the move that keeps a system standing when the data grows another ten times bigger.&lt;/p&gt;
&lt;p&gt;Full project: &lt;a href=&quot;https://hi.switchy.io/U4wS&quot;&gt;Customer Segmentation and Recommendation System&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>How we went from 42nd to 1st in a class AI contest</title><link>https://sampreethavvari.github.io/posts/resnet-under-5m/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/resnet-under-5m/</guid><description>A CIFAR-10 competition across 55 teams, a hard 5M-parameter cap, and two weeks. Tuning got us stuck at 92%. The thing that actually won it was almost embarrassingly simple.</description><pubDate>Sun, 08 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In our deep learning class, the professor turned the final into a competition. &lt;strong&gt;55 teams. Two weeks. One job:&lt;/strong&gt; build the best image classifier for CIFAR-10. Two rules. Keep it a ResNet, and keep it &lt;strong&gt;under 5 million parameters&lt;/strong&gt;. Inside those lines you could do anything, any ResNet variant, any trick you’d read about. The bar to clear was 85% accuracy.&lt;/p&gt;
&lt;p&gt;We cleared it. And then the leaderboard came out and we were sitting at &lt;strong&gt;42nd&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That did not sit right with me.&lt;/p&gt;
&lt;h2&gt;Two weeks, one stubborn ceiling&lt;/h2&gt;
&lt;p&gt;So I put my head down. I tried different ResNet architectures. I swept hyperparameters, learning rates, schedules, weight decay, batch sizes, until I found a combination that genuinely worked. Our accuracy climbed to about &lt;strong&gt;92%&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I shared the winning hyperparameters with my team. That jumped us to &lt;strong&gt;16th&lt;/strong&gt; in the class.&lt;/p&gt;
&lt;p&gt;Better. Not good enough. Because I could see where this was going: the teams ahead of us were hitting &lt;strong&gt;94 to 94.5%&lt;/strong&gt;, and no amount of extra tuning was closing that gap. Under a 5-million-parameter cap, the model can only get so smart. The rules said I couldn’t buy more accuracy with more parameters, and I’d already squeezed hyperparameters dry. I’d hit a wall.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;42nd → 16th&lt;/div&gt;
    &lt;div&gt;after architecture + hyperparameter tuning (~92%)&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;94 to 94.5%&lt;/div&gt;
    &lt;div&gt;what the teams ahead of us were hitting&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;5M params&lt;/div&gt;
    &lt;div&gt;the cap that made &quot;just tune harder&quot; a dead end&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;(If you want the receipts, every run and result is in our &lt;a href=&quot;https://docs.google.com/spreadsheets/d/1ZGsL-hlqXFQmKHCA-6-J81xIbBcJNZBSGYnldBHzYlA/edit?usp=sharing&quot;&gt;results sheet&lt;/a&gt;.)&lt;/p&gt;
&lt;h2&gt;So I asked a different question&lt;/h2&gt;
&lt;p&gt;If the model is already as good as the rules let it be, and the hyperparameters are dialed in, what’s actually left to improve?&lt;/p&gt;
&lt;p&gt;Not the model. The &lt;strong&gt;data&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;More data makes almost any model better. But there was a catch: we had to stick to CIFAR-10. We couldn’t go grab more images. So how do you get more data out of a fixed dataset?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Augmentation.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It’s a simple idea. Take each training image and make new versions of it on the fly: rotate it a little, flip it, crop out a piece, nudge the colors. The label doesn’t change, a cat rotated ten degrees is still a cat, but the model now sees far more variety. You get a flood of new training examples without collecting a single new image, and without adding a single parameter to the model.&lt;/p&gt;
&lt;p&gt;Which was the whole point. The cap was on &lt;em&gt;parameters&lt;/em&gt;, not on &lt;em&gt;data&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;The game changer&lt;/h2&gt;
&lt;p&gt;This is where it turned. Feeding my already-good models all that augmented data, accuracy just shot up. We landed at &lt;strong&gt;97.12%&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That put us &lt;strong&gt;2nd in the entire class&lt;/strong&gt;, a heartbreaking &lt;strong&gt;0.17%&lt;/strong&gt; behind the team in 1st.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;97.12%&lt;/div&gt;
  &lt;div&gt;final CIFAR-10 accuracy, 2nd of 55 teams, 0.17% off the top spot&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;We were thrilled. And then came the cherry on top.&lt;/p&gt;
&lt;h2&gt;The secret test set&lt;/h2&gt;
&lt;p&gt;The professor had held something back: a set of images &lt;strong&gt;none of us had ever seen&lt;/strong&gt; while building. After submissions closed, he ran every team’s model against it.&lt;/p&gt;
&lt;p&gt;Every model did worse on the secret set than on CIFAR-10’s own test data. That was the tell, most teams had quietly &lt;em&gt;memorized&lt;/em&gt; the CIFAR-10 test images. Their models knew the test, not the world.&lt;/p&gt;
&lt;p&gt;Ours dropped the least. By a lot. Because we’d trained on all those rotated, cropped, flipped versions, our model had learned the actual shapes of things instead of the exact pixels of the test set. On data it had never seen before, &lt;strong&gt;it came first in the whole class.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We did not see that coming. We partied that night. Genuinely did not know we had it in us.&lt;/p&gt;
&lt;h2&gt;Why I keep telling this one&lt;/h2&gt;
&lt;p&gt;Here’s the part I love. Going in, we thought we were average. Other teams were pulling out heavy machinery, teacher-student setups, exotic architectures, things we’d only half-heard of. We assumed someone like that would run away with it.&lt;/p&gt;
&lt;p&gt;Instead we:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;kept the problem simple&lt;/strong&gt; and refused to over-engineer it,&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;read everything&lt;/strong&gt;, papers and open-source repos, to see how the best people in the world were attacking CIFAR-10,&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;learned from them&lt;/strong&gt;, used a little common sense, and just put our heads down for two weeks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s it. No magic. The team that thought it was average finished 2nd on the test set and &lt;strong&gt;1st on the data that actually mattered&lt;/strong&gt;, the stuff the model had never seen. The professor handed us a bonus grade point for the effort.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The teams ahead of us had fancier models. We had more data and a model that generalized. On unseen data, generalization wins.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The lesson stuck: when the obvious lever (a bigger model) is off the table, don’t push harder on it, step back and find the lever nobody’s pulling. Ours was data. It cost zero parameters and won the only test that didn’t tell us the answers ahead of time.&lt;/p&gt;
&lt;p&gt;Full results: &lt;a href=&quot;https://docs.google.com/spreadsheets/d/1ZGsL-hlqXFQmKHCA-6-J81xIbBcJNZBSGYnldBHzYlA/edit?usp=sharing&quot;&gt;our CIFAR-10 competition sheet&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>How I rebuilt a 10-year-old pricing tool in a month</title><link>https://sampreethavvari.github.io/posts/treatment-estimator/</link><guid isPermaLink="true">https://sampreethavvari.github.io/posts/treatment-estimator/</guid><description>A 10-year-old pricing tool, rebuilt in about a month around one database rule that makes the 6-month price promise impossible to break.</description><pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Hybridge’s treatment coordinators were closing full-arch implant cases worth $24,000 or more while mentally adjusting numbers the software got wrong. The quoting tool was ten years old. It worked, roughly, but three problems had been quietly absorbing coordinator effort the whole time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One price for every clinic.&lt;/strong&gt; Rochester, Buffalo, and the planned Syracuse location all shared a single set of numbers. Want to charge differently? Fork the script.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No tiers.&lt;/strong&gt; Chelsea, our senior treatment coordinator, has been quoting full-arch implant cases for a decade with a structure like “first implant $2,800, second $2,300, third+ $1,800.” That structure lived in her head, not in the tool. Coordinators were hand-editing the printed estimate.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No price freeze.&lt;/strong&gt; Write a $24,300 quote today. Raise prices next month. Patient comes back to accept. The legacy tool happily reads the new price book. The “6-month guarantee” at the bottom of the PDF was a promise the software couldn’t keep.&lt;/p&gt;
&lt;p&gt;A vendor spent a decade trying to fix this. They never shipped. I shipped in a month.&lt;/p&gt;
&lt;h2&gt;The decoupling that made it all work&lt;/h2&gt;
&lt;p&gt;Before any code, I wrote an ADR with one decision the rest of the system falls out of:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Code-driven decision tree. DB-driven price catalog. Prices snapshotted onto each estimate at capture time.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;em&gt;flow&lt;/em&gt; of an estimator session lives in TypeScript. The &lt;em&gt;prices&lt;/em&gt; live in Postgres. They meet at one function. The result is written to disk in a form no later price update can touch.&lt;/p&gt;
&lt;h2&gt;Five pricing models, one resolver&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;flat            One price. Night guard, sedation, CT scan.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;tiered          Nth time you add this procedure, you pay tier-n&apos;s price.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                First implant cheaper than the second? Wait, no, more expensive.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;tiered_zoned    Same idea, but tiers are per zone. Premium #7-10 crowns&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                have their own ladder.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;price_range     Catalog gives min/max. TC picks within the range.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;per_surface     Surface count picks the tier. Fillings.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Exactly one function in the codebase decides what tier any line item is. Add a new pricing model and forget to update the resolver: the TypeScript compiler refuses to build. Compile-time guarantees on money.&lt;/p&gt;
&lt;h2&gt;The 6-month guarantee, as a database constraint&lt;/h2&gt;
&lt;p&gt;Every line item snapshots four things at write time: the price, the tier number, the procedure label, and the &lt;em&gt;price book version ID&lt;/em&gt; active at that moment.&lt;/p&gt;
&lt;p&gt;A Postgres trigger refuses any change to those columns:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; TRIGGER&lt;/span&gt;&lt;span&gt; trg_freeze_capture&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;BEFORE&lt;/span&gt;&lt;span&gt; UPDATE&lt;/span&gt;&lt;span&gt; ON&lt;/span&gt;&lt;span&gt; estimate_line_item&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;FOR&lt;/span&gt;&lt;span&gt; EACH &lt;/span&gt;&lt;span&gt;ROW&lt;/span&gt;&lt;span&gt; EXECUTE&lt;/span&gt;&lt;span&gt; FUNCTION&lt;/span&gt;&lt;span&gt; freeze_capture();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If application code tries to recompute a price into an existing line, Postgres throws. The 6-month guarantee is no longer a sticker on the PDF. It’s a database invariant.&lt;/p&gt;
&lt;div&gt;
  &lt;div&gt;1 month&lt;/div&gt;
  &lt;div&gt;From ADR to working end-to-end, versus a vendor attempt that never shipped in ten years&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;Per-clinic, with a lifecycle&lt;/h2&gt;
&lt;p&gt;Each clinic has its own &lt;em&gt;price book version chain&lt;/em&gt; with a status: &lt;code&gt;draft → proposed → approved → published&lt;/code&gt;. You edit a draft, propose it, get approval from an org admin, then publish with an effective-from timestamp. Past versions stay readable forever.&lt;/p&gt;
&lt;p&gt;Rochester at version 12 and Buffalo at version 8 coexist. No fork. No shared global table.&lt;/p&gt;
&lt;h2&gt;The wizard, the only piece of UI worth lingering on&lt;/h2&gt;
&lt;p&gt;The legacy tool’s real problem was that coordinators got lost mid-consult. Ten steps, no orientation. Patient is in the chair, watching the screen.&lt;/p&gt;
&lt;p&gt;The redesign stacks three things that tell the TC where they are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Top progress strip:&lt;/strong&gt; phase chips: ✓ done, ▶ here, ⌧ locked, skipped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Left rail checklist:&lt;/strong&gt; shows their answer in parentheses: &lt;code&gt;Extractions (#4, 6, 8) ✓&lt;/code&gt;. Click to jump back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Right rail “Selections so far”:&lt;/strong&gt; sticky, color-coded blue (upper) / green (lower), with running subtotals.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;By the numbers&lt;/h2&gt;
&lt;div&gt;
  &lt;div&gt;
    &lt;div&gt;1 → 5&lt;/div&gt;
    &lt;div&gt;pricing model kinds, one resolver&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;$35/mo&lt;/div&gt;
    &lt;div&gt;target cost at S1 volume on Cloud Run&lt;/div&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;div&gt;100%&lt;/div&gt;
    &lt;div&gt;branch coverage on the pricing engine&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Legacy&lt;/th&gt;&lt;th&gt;New&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Pricing models supported&lt;/td&gt;&lt;td&gt;1 (flat)&lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Per-clinic pricing&lt;/td&gt;&lt;td&gt;Hardcoded everywhere&lt;/td&gt;&lt;td&gt;First-class object with a lifecycle&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;6-month price guarantee&lt;/td&gt;&lt;td&gt;PDF sticker&lt;/td&gt;&lt;td&gt;Postgres trigger&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Wizard orientation&lt;/td&gt;&lt;td&gt;”Which step am I on?”&lt;/td&gt;&lt;td&gt;Three stacked affordances&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cost target&lt;/td&gt;&lt;td&gt;n/a&lt;/td&gt;&lt;td&gt;$35/month at S1 volume on Cloud Run&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Time to working end-to-end&lt;/td&gt;&lt;td&gt;The vendor 10 years ago: never shipped&lt;/td&gt;&lt;td&gt;About one month&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Drizzle and Postgres and Next.js are everywhere these days. The tools were not the hard part. The hard part was sitting with Chelsea until her mental pricing model was precise enough to write a schema for, writing nine ADR decisions before touching code, and knowing that a service-level check on price changes would always be one missed call away from a broken promise. A Postgres trigger cannot be skipped. A business guarantee probably can.&lt;/p&gt;
&lt;p&gt;That gap, between what an org says it does and what the system actually enforces, is where this kind of work lives. Draw the line in schema and code, and the next engineer extending it cannot blur it by accident.&lt;/p&gt;</content:encoded></item></channel></rss>