<?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.com/</link><item><title>Nine accounting scripts moved from a laptop to one page in an evening</title><link>https://sampreethavvari.com/posts/accounting-automation/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/accounting-automation/</guid><description>Our controller ran nine Python scripts by hand, with 26 terminal prompts between them. I put them behind one page that asks every question before the run, in one evening.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our controller turned exports into books with nine Python scripts on a Windows laptop. Each one found its file in the Downloads folder by a hard coded path, stopped halfway to ask questions in a terminal, and wrote its result back to Downloads. Out came QuickBooks import files from Denticon, the lab system, Ramp and the bank, a cleaned Paychex time off workbook and a receivables aging report. Two of the scripts wrote straight into Google Sheets.&lt;/p&gt;
&lt;p&gt;Between them the scripts called &lt;code&gt;input()&lt;/code&gt; &lt;span&gt;26 times&lt;/span&gt;. Which deposit account does this cash payment go to? Which department does this new employee belong to? Which of these vendors does this ACH line match? Write this tab or skip it?&lt;/p&gt;
&lt;p&gt;Our controller asked for one internal site: upload a file, have it pick the right script, ask what it needs, run it, and hand back the output. Our controller also wanted to edit the scripts from a browser and see a diff before publishing.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Accounting Tools, a FastAPI app on Cloud Run behind Google’s Identity Aware Proxy. Upload an export and the page recommends a script from the file name. The script’s questions come up as one form, then it runs and leaves a file to download, or rows in a Sheet.&lt;/p&gt;
&lt;p&gt;Around that: a Scripts tab with an editor, a diff view, version history and revert, a Reference files tab for the three workbooks the scripts keep, a Run history tab, and an Access tab for admins. A second service built from the same image runs the checks import, which pulls check sheets out of Gmail into the outstanding checks Sheet. &lt;a href=&quot;/architecture/accounting-automation&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;All of it happened on May 20. The spec was approved that day and the first commit went in at 4:12 PM. The shared contract every script would follow came minutes later. By 4:56 PM one script, the Ramp reformatter, worked from upload to download with an end to end test. The first live revision went up at 5:14 PM, 62 minutes after the first commit.&lt;/p&gt;
&lt;p&gt;Then the rest of the upload scripts moved onto the contract between 5:44 PM and 6:22 PM, simplest first. The checks import had its own live service by 6:57 PM.&lt;/p&gt;
&lt;p&gt;The editor our controller asked for was live at 7:56 PM: open a script, change it, see the diff, publish. That was 3.7 hours after the first commit. The Access tab followed at 8:20 PM and the last change of the night at 8:46 PM.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A web page cannot wait at a prompt.&lt;/strong&gt; A terminal script can stop and wait for someone to type. A web request cannot. So each script now has two entry points. Prescan reads the file and the reference workbooks, writes nothing, and returns every question the run will need. Run takes the answers and does the work. A small adapter in each script turns the JSON it gets into one or the other:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# scripts/_lib.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; mode &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &quot;prescan&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    result &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; prescan_fn(ctx)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;elif&lt;/span&gt;&lt;span&gt; mode &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &quot;run&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    result &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; run_fn(ctx, request.get(&lt;/span&gt;&lt;span&gt;&quot;answers&quot;&lt;/span&gt;&lt;span&gt;, {}))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cashflow import, the largest, turned ambiguous ACH matches, unmatched vendors and a confirm for each Sheet tab into one form. Deleting the matched outstanding checks became a second step after the run, with its own confirm.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Running code that can change.&lt;/strong&gt; Scripts can be edited from the browser, so the server never imports one. Each runs as its own process with JSON in, JSON out and a timeout, so a broken script returns an error and the server keeps going. A publish first compiles the new code and runs that script’s smoke test in a copy of the project. If either fails, the editor shows the error and nothing is committed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two bugs that had been there all along.&lt;/strong&gt; In the aging report, the list of platinum customers was missing a comma, so Python &lt;span&gt;joined two customer numbers into one&lt;/span&gt; and one platinum customer dropped off the list. In the Denticon production import, the line that names the output file was indented into the block above it, so some paths reached the write with no file name. Both were fixed in the move, and the comma has a regression test.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Rewriting the scripts.&lt;/strong&gt; They stayed in Python with their logic intact, so our controller still recognizes them. I changed where they read and write, how they ask, and the two bugs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A database for reference data.&lt;/strong&gt; The ACH matcher, the cashflow matcher and the department list stayed Excel workbooks. Our controller works in Excel and sometimes downloads one, edits it and puts it back. On a GCS Fuse mount they are plain files to the scripts, and bucket versioning keeps each old copy for 30 days.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A general platform.&lt;/strong&gt; The spec ruled out more users, other scripts, phones and a custom domain. It runs these nine scripts for our controller, and that kept it to an evening.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It went live on May 20 for our controller. The Google Cloud project’s billing is off now, so the site is offline. I have no record of how often it was used while it ran, no measured cost, and no measure of time saved, so I am not going to guess at one.&lt;/p&gt;
&lt;p&gt;What I would keep: the prescan and run split. Asking every question before anything is written made the old terminal scripts work in a browser without changing what they do.&lt;/p&gt;</content:encoded></item><item><title>A twenty copy Excel workbook became a live capacity planner</title><link>https://sampreethavvari.com/posts/capacity-planner/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/capacity-planner/</guid><description>Our operations lead planned three offices in a workbook of pasted week blocks. I replaced it with a planner that reads Denticon every minute and cannot write back. Seventeen days to production.</description><pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our operations lead planned which doctor works where across Rochester, Buffalo and Syracuse in one Excel workbook. Every week was a block of formulas pasted under the last one. Twenty copies of the same week, and when the board ran out you pasted another.&lt;/p&gt;
&lt;p&gt;It worked better than it had any right to. It also failed quietly. The row that counted Rochester’s doctors was a COUNTIF over rows 6 to 11, so a doctor added on row 12 did not exist. Typing over a cell to move someone for a day deleted its formula, so that cell forgot about time off. And none of it knew what Denticon, our practice system, actually had booked.&lt;/p&gt;
&lt;p&gt;The real cost was people. Last minute coverage calls, and a doctor slammed at one office while another office had a free chair.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;One forward view of demand, capacity and capability for all three offices. It reads Denticon every minute through the CDF, my service that holds the Denticon credentials, and it has no way to write back. A person still makes every real change in Denticon. The planner shows who is where, which day has patients and no doctor, who is overloaded, and who could cover.&lt;/p&gt;
&lt;p&gt;All the planning math lives in one pure TypeScript package with no database, no network and no clock. The same code runs in the minute sync on the server and in the browser when someone asks what happens if a doctor takes Thursday off. &lt;a href=&quot;/architecture/capacity-planner&quot;&gt;Open the drawing&lt;/a&gt; to see every part.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;On September 11 I took the workbook apart cell by cell, wrote the spec, and sent our operations lead eleven questions. Each one already had a default coded, so nothing waited on an answer. The first commit went in the same day.&lt;/p&gt;
&lt;p&gt;v0.1.0 hit production at 3:27 PM on September 28. By 6:50 PM the People screen, the Bandwidth screen, the Coverage board and the Projector were live too. The first full read of live Denticon data landed at 5:34 the next morning, about 12,300 appointments. On October 1 a fix went in at &lt;span&gt;4:53 AM&lt;/span&gt;. Later that day a practice director wrote, word for word, “I need an undo button!” It shipped as v0.38.0 at 3:34 PM the same day.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Denticon outranks the plan.&lt;/strong&gt; Live data broke two assumptions within hours. Denticon books the doctor as the provider on visits in the assistant’s chair too, so one Buffalo doctor stacked three rows deep. And Syracuse showed “Need Dr” on a day Denticon had two doctors booked there. The roster said one thing, the bookings said another, and the bookings were right. So every day now runs one ordered chain: an override, then time off, then a Denticon block note, then a booking, then the doctor calendar, then the weekly pattern. Each cell says which rule placed it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The bug that could have cancelled an office.&lt;/strong&gt; A full read upserts what the CDF lists, then cancels what the planner holds that the CDF no longer lists. That is how a cancelled visit disappears. It is also a loaded gun. I found an older CDF route that dropped an unknown office code instead of refusing it, which would have read as “this office has no appointments” and swept its whole calendar away. So the routes refuse unknown offices, a full read writes nothing unless every 31 day window answered, and the sweep counts before it cuts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// apps/web/lib/import/syncBatch.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (stale &lt;/span&gt;&lt;span&gt;&amp;gt;=&lt;/span&gt;&lt;span&gt; batch.guard.min &lt;/span&gt;&lt;span&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span&gt; stale &lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; batch.guard.maxShare &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; active) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  throw&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; MassCancelError&lt;/span&gt;&lt;span&gt;(locationCode, stale, active);&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;More than half of an office’s rows and at least 20 of them, and the whole read rolls back. The error names only an office and two counts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Patient names stay out of logs.&lt;/strong&gt; An ORM upgrade for a security fix started putting SQL and every bound parameter into error messages. A parameter can be a patient’s name. The logger now describes a failed query by its Postgres code, constraint and table only, and an integration test drives a real duplicate insert with a patient style name to prove no log line carries it.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Writing to Denticon.&lt;/strong&gt; The contract every appointment source implements has only read methods, and a &lt;span&gt;type level test&lt;/span&gt; fails the build if anything shaped like a write appears.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generated advice on who to place.&lt;/strong&gt; Cover suggestions rank people on five plain rules: can do the work, will work at that office, already there, provably free, preferred assistant free too. Ties break on person id. Every reason on screen comes from a rule our operations lead can read.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Guessing from free text.&lt;/strong&gt; Staff type notes into Denticon blocks. The reader acts only on a note that names exactly one doctor and says plainly out or here. On my local copy it acted on about one noted block in ten. I would rather miss a note than move a doctor on a guess.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;The operations lead and the office directors plan from it every day. Each tab answers one question: Calendar, Coverage, Needs action, Bandwidth, and since October 20 a Room Board where people and blocks drag between rooms with Undo. A 7 AM email goes to the office directors Monday to Friday with what needs action.&lt;/p&gt;
&lt;p&gt;It costs $27 a month to run. The database is nine tenths of that. The sync that runs every minute, all month, costs $1.29.&lt;/p&gt;
&lt;p&gt;I have not measured hours saved. I do not have a before number for how long the workbook took each week. Office hours for each site and categories for 44 appointment types are still waiting on answers.&lt;/p&gt;
&lt;p&gt;What I would keep: write the old system’s rules down cell by cell, make the new engine prove it reproduces them, and get onto live data on day two. And anywhere software can cancel something at scale, make it refuse first and explain itself second.&lt;/p&gt;</content:encoded></item><item><title>A dental CT scan checker in 38 days, on CPU, for $62 a month</title><link>https://sampreethavvari.com/posts/cbct-scan-validator/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/cbct-scan-validator/</guid><description>A vendor quoted $98,200 to flag bad dental CT scans. I built the checker in house in 38 days. It runs on Cloud Run CPU, and our testing team checks it on real studies.</description><pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our design team plans implant cases from CBCT scans, the 3D X-rays taken in our offices. Some scans arrive unusable. The patient moved, metal threw streaks, or part of the jaw sat outside the field of view. Nobody knew until a designer opened the case, flagged it and waited on a rescan. My April brief to leadership put the cost at 24 to 48 hours per bad scan.&lt;/p&gt;
&lt;p&gt;The same brief estimated about 600 scans a month, so a 10 to 15 percent bad rate meant 60 to 90 cases a month waiting on a rescan.&lt;/p&gt;
&lt;p&gt;A vendor had quoted $98,200 to build a checker and $2,200 a month to run it. It would answer good or bad. I built one in house.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A quality gate between the lab’s case system and the design team. MagicTouch DLCPM, where the lab’s cases live, can export each case over SFTP. The zip lands on a small locked down VM I run, which copies it into Cloud Storage. The new object fires Eventarc, and the validator on Cloud Run scores the scan: a frozen dental segmentation encoder compiled for CPU, a small trained head, and a rules router that names the artifact and the next step. The verdict PDF goes back through the same SFTP box, named so DLCPM attaches it to the right case.&lt;/p&gt;
&lt;p&gt;Two pieces feed it. A daily job turns labeled scans in Drive into training tensors. A review app lets our testing team upload a real study, see the verdict and record the true answer. &lt;a href=&quot;/architecture/cbct-scan-validator&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first commit went in on March 25 at 6:27 PM. By April 7 six candidate models sat behind one interface.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;April 22, 3:57 AM: first deploy to Cloud Run. A real CBCT zip went through end to end in 26.7 seconds. v0.4.0 was tagged at 7:36 AM.&lt;/li&gt;
&lt;li&gt;April 27, 6:45 PM to 11:51 PM: the training data job got a lock after overlapping runs multiplied the work, and 16 GiB of memory after a 400 MB scan zip ran it out of 4.&lt;/li&gt;
&lt;li&gt;April 29: MagicTouch confirmed its API license had lapsed. By 3 PM the SFTP route both ways was live and verified end to end with a test zip.&lt;/li&gt;
&lt;li&gt;May 1: Model H, the first model that worked, landed at &lt;span&gt;5:44 AM&lt;/span&gt;. v0.6.1 went out at 11:42 PM.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From the first commit to Model H took 38 days.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The 0.80 that leaked.&lt;/strong&gt; The 5:44 AM model scored 0.80 AUROC. Seven of the ten bad holdout studies had been in its training data. The older trainer already refused to start on a holdout study, and the new Model H script had no such check. I rebuilt its split so all 20 holdout studies sit in test only.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A 0.95 that meant nothing.&lt;/strong&gt; On the clean split, a bigger head with about 4.5 million trainable parameters and 117 training studies reported 0.95 AUROC on validation. A diagnostic script showed every scan getting nearly the same output: good, at about 88 percent confidence. The 0.95 was measuring noise. I cut the head to about 500,000 parameters and the scores spread out. On 44 test studies the result was 0.6309 AUROC, catching 7 of 11 bad ones. The commit calls it the honest baseline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thresholds priced in rescans.&lt;/strong&gt; A bad scan that reaches design wastes hours. A flagged good scan wastes minutes. So the threshold minimises &lt;span&gt;15 times the misses&lt;/span&gt; plus the false alarms, tuned on validation data only:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# scripts/build_and_eval_dentalseg_head.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; thr &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; candidates:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    pred &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (scores_a &lt;/span&gt;&lt;span&gt;&amp;gt;=&lt;/span&gt;&lt;span&gt; thr).astype(&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    fn &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; int&lt;/span&gt;&lt;span&gt;(((ys_a &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;&amp;amp;&lt;/span&gt;&lt;span&gt; (pred &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; 0&lt;/span&gt;&lt;span&gt;)).sum())&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    fp &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; int&lt;/span&gt;&lt;span&gt;(((ys_a &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;&amp;amp;&lt;/span&gt;&lt;span&gt; (pred &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; 1&lt;/span&gt;&lt;span&gt;)).sum())&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    cost &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; fn_fp_cost_ratio &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; fn &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; fp&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That picked 0.176. The eval also lists the studies within 0.10 of it as the next ones worth labeling.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The API license.&lt;/strong&gt; MagicTouch’s API needs a Digital Gateway license, and ours had lapsed. DLCPM’s Import and Export Digital Files feature works over SFTP in both directions and matches files to cases by name. Only status flips and automatic calls need the API, and front desk staff can flip a status by hand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A GPU in production.&lt;/strong&gt; OpenVINO ran the 30.79 million parameter encoder in 2.20 seconds per pass on a laptop CPU. PyTorch took 2.56 seconds and ONNX Runtime alone 3.93. The validator runs on Cloud Run CPU and scales to zero.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Learning classes from one example.&lt;/strong&gt; Eight of the 13 classes had a single training scan each. A learned 13 class head was memorising them, so I removed it. The model predicts good or bad and an artifact family, and a rules router assigns the class.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;The validator has been deployed since April 22 and on v0.6.1 since May 1, tested end to end with test zips. DLCPM’s export to the SFTP box is a setting on the lab’s side that has not been switched on, so no real lab case has gone through yet.&lt;/p&gt;
&lt;p&gt;The testing team uses the review app. Since August 19, three people have uploaded 123 real studies on 38 different days and recorded the true answer on 103. Of the 30 they marked bad, the model had flagged 26. It also flagged 44 of the 73 good ones. Its threshold was set to catch about nine in ten bad scans. 90 reviewed studies have joined the training pool.&lt;/p&gt;
&lt;p&gt;It cost $62 on Google Cloud in October, measured. About three quarters of that was the training ETL, which woke every 5 minutes on 4 CPUs and 16 GiB, 53,086 runs since April. On October 28 I moved it to once a day at 6 AM. The drop is not measured yet.&lt;/p&gt;
&lt;p&gt;Not measured: rescans avoided and designer hours saved. Those need real lab cases flowing through.&lt;/p&gt;
&lt;p&gt;What I would keep: freeze the holdout before the first model, and make every training script refuse to run when a holdout study shows up.&lt;/p&gt;</content:encoded></item><item><title>Gemini reads the patient first, and the clinic waits for both reports</title><link>https://sampreethavvari.com/posts/cdf-diagnostic-filter/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/cdf-diagnostic-filter/</guid><description>Intake and experience surveys with Gemini writing the doctor&apos;s page and a panorex read under the founder&apos;s own rules. Every call falls back through newer models, and clinic emails wait for both reports.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;In June our founder gave me an overview of what the founder called the Centralized Diagnostic Filter. Its first rule was in capitals: the goal is “NOT simply to create AI that reads radiographs.” The goal was every input a complex case generates turned into one consistent first read before the consult. And one line that does not bend: “AI assists, doctors validate.” The final diagnosis stays with the doctor, which the founder called “critical both clinically and legally.”&lt;/p&gt;
&lt;p&gt;The front door was broken first. The pre consult assessment ran on a third party intake platform. My June 19 spec lists what I was told: the practice did not own its patients’ answers, one person could edit a question or add a recipient, a staff member screenshotted pages of each report into OneNote and filed it in Drive by hand, and three offices ran three copies of one questionnaire because the vendor could not route by office.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Two surveys, a report pipeline, and the AI that reads them. The intake is one questionnaire for three offices; the experience survey takes spoken answers too. A submit becomes one named Cloud Task. A renderer makes the patient’s summary, with no risk score on it, and a doctor report with a psychographic page Gemini writes from the answers. It emails the patient, then the office’s clinic list, and files everything to Drive.&lt;/p&gt;
&lt;p&gt;On demand from the dashboard, the diagnostic read sends the patient’s panorex and photos to Gemini under a ruleset built from four cases our founder read aloud. Six features call Gemini, and all six go through one function that retries and falls back to newer models. &lt;a href=&quot;/architecture/cdf-diagnostic-filter&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first commit went in at 1:48 AM on June 19, and a branded shell was on Cloud Run at 3:06 AM. By 11:49 PM the renderer, delivery and an append only audit log were in. By June 24 Gemini 2.5 Pro was writing the psychographic. On July 8 the founder’s written review became ruleset v2.&lt;/p&gt;
&lt;p&gt;August 24 was the hard day. Twice, the patient had their summary and the clinic got nothing, and no error surfaced anywhere. By that night I had shipped CPU kept on after the reply, memory sized for a browser per render, a reconcile sweep, then Cloud Tasks retries with a render that is safe to repeat. Two days later the experience survey went from first commit at 2:15 PM to last at 10:09 PM, voice input included.&lt;/p&gt;
&lt;p&gt;When the practice rewrote the old ambassador question into ways a delighted patient can share their story, their approved copy was live on October 5 at 12:22 PM.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The clinic email waits for both reports.&lt;/strong&gt; On October 22 one intake’s psychographic hit a single 429 from Vertex. Nothing tried again, so the clinic got the summary alone, and nothing would have gone back for it. Since October 23 the clinic email goes with both files or not yet. The hold is written on the report, the render answers 500, and Cloud Tasks comes back, up to ten attempts, with one alert before the last. Then the hourly sweep keeps trying, and it leaves the last &lt;span&gt;75 minutes&lt;/span&gt; alone so it never races a retry and mails a list twice. The patient never waits for any of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every Gemini call has a way out.&lt;/strong&gt; The same week Google listed 2.5 Pro and 2.5 Flash as retired on October 20, while both still answered. So every call goes through one chain:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// packages/lib/src/gemini-models.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; const&lt;/span&gt;&lt;span&gt; DEFAULT_GEMINI_MODELS&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Record&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;GeminiTier&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;readonly&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt;[]&amp;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;  pro: [&lt;/span&gt;&lt;span&gt;&apos;gemini-2.5-pro&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;gemini-4-argon&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;gemini-3.1-pro-preview&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;gemini-3.8-flash&apos;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  flash: [&lt;/span&gt;&lt;span&gt;&apos;gemini-2.5-flash&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;gemini-3.8-flash&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;gemini-3.5-flash&apos;&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;A 429, 5xx or timeout gets up to three tries on the same model, with a 2 to 20 second wait. A 404 means retired: skip it for six hours and send one alert. Anything else moves to the next model. All of it runs inside a time budget, 120 seconds for the psychographic, so a retry never outlasts the request. I checked all four pro models live on October 23.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The model observes, the code counts.&lt;/strong&gt; Our founder read four panorexes on camera, narrating every call. Each rule in the ruleset cites its case and timestamp, and anything I synthesized is marked for the founder to confirm. Gemini lists which teeth are decayed, missing and filled. Plain code recomputes the destruction score and its band, so a model slip cannot produce a wrong total. The feedback summary works the same way: every point must cite a real quote, and one that does not is dropped in code. Where the model is weak, I measured it. Its tooth positions on a panorex were 50 to 70 percent wrong, so &lt;span&gt;tooth numbering&lt;/span&gt; got its own evaluation before anything else was added.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A diagnosis or a treatment path.&lt;/strong&gt; The read is decision support. It never states a final diagnosis, and it does not pick a treatment path. That waits on our founder explaining, on the record, how a path gets chosen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The risk score on the patient’s copy.&lt;/strong&gt; The patient gets a cover and a one page summary. The 0 to 200 score is for the doctor to explain in the room.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mixing that score into the panorex read.&lt;/strong&gt; The score stays separate until our founder reviews its weights. Answers like smoking or dry mouth can only raise risk.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Patients at all three offices fill in the intake before a consult. By September 30 there were 103 real intakes. Each office’s list is read at send time, so changing a recipient needs no deploy. I have not measured how often staff run the panorex read. The billing export for this project only switched on October 28, so there is no measured month of cost yet. Tooth numbering is still an open evaluation, because no ready to run open model for it exists.&lt;/p&gt;
&lt;p&gt;What I would keep: anything that reports success while a clinic got nothing is the bug. Make the failure loud and the retry safe.&lt;/p&gt;</content:encoded></item><item><title>One registry serves the Denticon book to every Hybridge app</title><link>https://sampreethavvari.com/posts/cdf-registry/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/cdf-registry/</guid><description>Two apps needed the appointment book, and only one service may hold the Denticon keys. I built a small read only API over our mirror, with a secret per caller and strict offices.</description><pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Denticon, our practice system, holds every appointment at the three offices. On September 21 two of my apps needed that book.&lt;/p&gt;
&lt;p&gt;The treatment estimator was getting a new landing page. My commit that day put it plainly: “The thing a coordinator opens this app to find out is who is coming in today.” The capacity planner, which places doctors across the three offices, needed the whole book every minute.&lt;/p&gt;
&lt;p&gt;One rule stood in the way, and I had written it myself: only the CDF holds Denticon credentials, and no other repo gets its own, not even as a fallback. The planner’s early plan had reserved Denticon secrets in its own project, and my handoff flagged that as a contradiction to fix.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A registry: five read only routes on the CDF dashboard service, over the Firestore mirror that the &lt;a href=&quot;/posts/denticon-sync&quot;&gt;Denticon sync&lt;/a&gt; keeps fresh. The estimator gets one local day of the book, joined to patient and provider names. The planner gets the book by date window or by what changed, plus providers, visit types and chairs.&lt;/p&gt;
&lt;p&gt;Each caller has its own bearer secret, compared in constant time, and the registry never calls Denticon. Answers to the planner carry each office’s mirror health, so it can show old data as old. &lt;a href=&quot;/architecture/cdf-registry&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first route went in at 1:42 PM on September 21. At 6:19 PM a run against real data found the query had no index, which would have shown the estimator an empty day while the log said “failed after 300ms: Error”.&lt;/p&gt;
&lt;p&gt;On September 28 both sides went live three minutes apart: the estimator’s new landing page at 1:50 AM and the route it reads at 1:53 AM.&lt;/p&gt;
&lt;p&gt;The planner’s routes went in between 3:24 AM and 4:22 AM on September 29 and were live at 5:04 AM. At 5:31 AM the planner’s production was reading its appointments through them.&lt;/p&gt;
&lt;p&gt;On October 1 the registry started serving a block’s text as a note, so a reserved hour could read &lt;span&gt;LUNCH&lt;/span&gt; on the planner. Both sides shipped that day.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A day is a local day.&lt;/strong&gt; A day is computed per office from the office table, with the offset read from &lt;code&gt;Intl&lt;/code&gt;, because March 8, 2026 is 23 hours long and November 1 is 25. A fixed minus five puts a March morning appointment on the previous day.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What counts as a consult.&lt;/strong&gt; An appointment carries only a production type id. Consults are types 8 and 19, In Off Consult and Zoom Consult. At 6:03 PM on September 21 I took out NP Intake, the paperwork visit before the consult: counting its 1,054 visits in the staging mirror would have put consults at 1,693 instead of 639. Rows from before the Rochester migration carry no type, so the route reports how many it could not judge.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A dropped office is a cancelled calendar.&lt;/strong&gt; The planner cancels whatever a full read does not return. The estimator’s day view can drop a mistyped office and lose nothing, but on the planner’s routes an unknown office answers 400:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// lib/registry/shared.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; office&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; byEstimatorCode[code &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; EstimatorCode&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;office) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; { ok: &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;, error: &lt;/span&gt;&lt;span&gt;OFFICES_ERROR&lt;/span&gt;&lt;span&gt; };&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A window over 10,000 rows answers 413, and a change set over 5,000 tells the caller to read windows, because a sweep stamps one time on every row and leaves nothing to page on.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Denticon credentials in the planner’s project.&lt;/strong&gt; The planner holds one bearer for the registry and nothing else. Revoking it does not touch the estimator’s.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live calls to Denticon per request.&lt;/strong&gt; Every answer comes from the mirror in Firestore.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One shared secret.&lt;/strong&gt; Each caller gets its own, so a leak in one app is one rotation.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Two apps read the registry: the estimator’s landing page, the first screen every coordinator sees, and the planner, which reads the book and the provider list every minute.&lt;/p&gt;
&lt;p&gt;Its cost is not measured: the billing export for the CDF’s project only switched on Oct 28, 2026. I have not counted requests either.&lt;/p&gt;
&lt;p&gt;What I would keep: a route that refuses what it cannot answer correctly. An empty answer that looks valid is the one a caller acts on.&lt;/p&gt;</content:encoded></item><item><title>The CEO&apos;s consult checklist now grades every Zoom consult</title><link>https://sampreethavvari.com/posts/clinical-rag/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/clinical-rag/</guid><description>Consult reviews were written by hand against a seven criterion Word doc. Now a Zoom webhook starts a Gemini grader, and in October the median report landed 2.7 minutes after the transcript.</description><pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our CEO has a framework for a good implant consult. Seven steps: build rapport, earn credibility, pick a treatment path, create value, present the fee, agree how to follow up, and stay compassionate throughout. It lived in a Word doc. A consult review was a document someone wrote by hand, one consult at a time.&lt;/p&gt;
&lt;p&gt;The ask was simple to say. Score every doctor against that framework after every recorded consult, get a color coded report to the doctor, the treatment coordinator (TC) and the CEO within minutes of the transcript, and log every consult to one master sheet.&lt;/p&gt;
&lt;p&gt;Two facts made it harder. Any doctor can pair with any TC, so the system has to work out who the doctor was on every call. And I had no Super Admin rights in our Google Workspace.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;One Cloud Run service in Python. Zoom posts a webhook when a transcript is ready. The service checks the signature, answers 202, and works in the background: skip what is not a consult, find the doctor, ask Gemini 2.5 Pro for a scorecard as JSON, render the report and PDF, draft a chart note, file it in Drive, send the emails, and append one row to the master Sheet.&lt;/p&gt;
&lt;p&gt;The rubric has 7 criteria and 25 sub checks. Every rationale must quote the transcript, with a real timestamp or none. Each score is colored against that doctor’s own 30 day average, so it coaches a doctor against themselves. &lt;a href=&quot;/architecture/doc-coach&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first commit landed May 19 at 7:28 PM, already the working pipeline, deployed the same evening. The first consult went through it at 8:36 PM that night. I kept going until 2:17 AM, including a fix for Zoom payloads that were being dropped. By 6:15 PM on May 20 it had a four tier test suite and CI.&lt;/p&gt;
&lt;p&gt;Then the people using it shaped it. TCs paste the chart note into Denticon, our practice system, and copying from a PDF turned every wrapped line into a hard break. On July 1 the note became its own email in plain, copyable text. A TC sent three corrections on surgical stages, palliative offers and option order; they went into the prompt on July 6.&lt;/p&gt;
&lt;p&gt;On July 29 I ran 392 older consults, November 2025 to May 2026, through the same pipeline so the charts start in November.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A truncated scorecard needs more room.&lt;/strong&gt; On September 11 a consult failed scoring, and &lt;span&gt;four reruns&lt;/span&gt; failed the exact same way. It looked like Vertex being flaky. The cause was the budget. On Gemini 2.5 Pro the thinking spends the same output budget as the answer: a scorecard is about 3,200 tokens and thinking ran 3,500 to 4,400 on identical input, against a ceiling of 8,192. Cross it and the JSON stops mid string. Five more consults had hit it the month before. Asking again at the same ceiling fails every time, so the scorer now tells the two failures apart:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# zoom-transcript-pipeline/app/scorer.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; truncated:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    budget &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; min&lt;/span&gt;&lt;span&gt;(budget &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;_MAX_MODEL_OUTPUT_TOKENS&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    prompt &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; _HARDENED_PREFIX&lt;/span&gt;&lt;span&gt; +&lt;/span&gt;&lt;span&gt; base_prompt&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A response that did not finish cleanly is never accepted, even when it happens to parse. The ceiling also went to 32,768, which costs nothing when unused: thinking measured 3,534 tokens at 8,192 and 3,589 at 32,768. In October, no consult needed a second scoring attempt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who was the doctor?&lt;/strong&gt; Three layers: Zoom participants matched against the doctor directory, then transcript speaker labels, then the names Gemini pulls out while scoring. A layer that matches two doctors falls through to the next. Short aliases like a first name only match as whole words, or a &lt;span&gt;doctor’s first name&lt;/span&gt; fires inside patient names and small talk. If all three fail, the report goes to the CEO alone, flagged unresolved. Of the 392 regraded consults, none ended unresolved.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A half note that looked whole.&lt;/strong&gt; In July a chart note went out cut off after the General Health section: 869 characters where a normal note runs about 5,000. Three holes lined up: nobody checked why the model stopped, the 4,096 token budget was shared with thinking, and the check accepted any 3 of 7 sections. Now an unclean finish is rejected, all seven sections must be there, and the retry doubles the budget.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Waiting on domain wide delegation.&lt;/strong&gt; That needs a Super Admin, and I am not one. The service files and sends with a user OAuth grant whose refresh token lives in Secret Manager, so nothing waited on a permission.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A cheaper model before proving quality.&lt;/strong&gt; The config says to drop to Flash only after validating quality on the fixture set. Scoring, notes and report cards all still run on 2.5 Pro.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scores outside the app.&lt;/strong&gt; When the CDF dashboard needed consult details, it got a route that returns the note, recording and transcript, and never a score. The fields are an allowlist, and a test feeds it a record stuffed with scores and checks that none come back.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;From May 19 to October 28 it scored 471 consults live: 325 in Rochester, 91 in Buffalo and 55 in Syracuse. In October the median consult went from transcript to sent report in 2.7 minutes, and nine in ten took under 3.8. Doctors, TCs and the CEO get the report, TCs get the chart note, and every Monday the scores become report cards.&lt;/p&gt;
&lt;p&gt;Cost is not measured yet. The billing export for this project only switched on October 28, 2026, so there is no measured month. Since October 29 the warm instance only runs 7 AM to 7 PM.&lt;/p&gt;
&lt;p&gt;I have not measured whether the reports change how consults go. The dashboard joins scores to case outcomes, but there is no clean before and after to quote.&lt;/p&gt;
&lt;p&gt;What I would keep: make the model’s output a contract the code can check, and when it breaks, find out which way it broke before you retry.&lt;/p&gt;</content:encoded></item><item><title>Every number defined once, and cash flow read every morning</title><link>https://sampreethavvari.com/posts/cowork-dashboard/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/cowork-dashboard/</guid><description>Our leadership numbers came from Monday boards joined by name. Fixing the join surfaced about $460K nobody attributed. Then I moved it to Cloud Run, defined every metric once, and made cash flow daily.</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;The practice runs on Monday, a work board app: a Leads board, plus a patient board each for Rochester, Buffalo and Syracuse. Leadership reviews come from those boards. The first dashboard joined patients to leads by name. In a review on June 3, Buffalo’s consults and case acceptances read zero. Our chief sales officer asked: “What does that mean? The join was failing.”&lt;/p&gt;
&lt;p&gt;I measured it. Only 49.5 percent of Rochester patients and 65.3 percent of Buffalo patients linked to a lead. Every funnel was off by a different amount.&lt;/p&gt;
&lt;p&gt;Later our finance lead asked for the cash booked to start this month, every day.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A dashboard that turns the Monday boards into one snapshot. Each week the four board exports go into a Drive folder. A private service checks it every five minutes, rebuilds when all four files have changed, writes an immutable snapshot to Cloud Storage and moves a pointer to it last.&lt;/p&gt;
&lt;p&gt;&lt;span&gt;Every morning at six&lt;/span&gt;, the same check reads the three patient boards from the Monday API for the Cash Flow tab. Anyone can also press Refresh now.&lt;/p&gt;
&lt;p&gt;The browser pins the snapshot and computes all nine tabs with the same code the ingest runs. The business rules our operations lead signed off were ported line for line. &lt;a href=&quot;/architecture/cowork-dashboard&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first version was Apps Script, built in the spring. There, joining on the patient boards’ own connect column to Leads took linkage to 98.8 percent in Rochester and 99.9 in Buffalo. The 25 patients left over were retreatment cases filed without a Lead row. Together they carried about $460K of patient value nobody had attributed.&lt;/p&gt;
&lt;p&gt;The move to Cloud Run landed at 4:59 PM on September 2. Both Cloud Run services were created four minutes later.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sep 15: the Cash Flow tab.&lt;/li&gt;
&lt;li&gt;Sep 30, 12:29 AM to 12:54 AM: the restyle in the Hybridge design.&lt;/li&gt;
&lt;li&gt;Oct 8: the daily Monday read at 2:45 PM, and the pace against a usual month at 5:01 PM.&lt;/li&gt;
&lt;li&gt;Oct 22: calendar presets for the Cash Flow tab.&lt;/li&gt;
&lt;li&gt;Oct 23: a Feature requests tab wired to Launchpad.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On October 26 at 4:39 PM our finance lead filed a request in that tab: “please add a quick toggle after MTD with Rest of Month.” It was live at 12:03 PM the next day. On October 28 at 11:44 AM came a second: “Rest of Month toggle should include current day.” It was live at 12:03 PM, 19 minutes later.&lt;/p&gt;
&lt;p&gt;Each merge to main runs the tests and deploys both services.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Three copies of every metric.&lt;/strong&gt; In May I wrote that every metric lived in one function, so two tabs could not disagree. Porting the code proved it wrong. The old build had one implementation for the card, one for the drilldown list behind it, and one for the trend overlay. Three copies of “what counts as a case acceptance” is how a card and its own drilldown showed different numbers. Now each metric is defined once and the card number is computed from the drilldown’s rows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// packages/core/src/metrics.ts, inside metricValue()&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; def&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; DEFS&lt;/span&gt;&lt;span&gt;[key];&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;def) &lt;/span&gt;&lt;span&gt;return&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;const&lt;/span&gt;&lt;span&gt; rows&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; metricRows&lt;/span&gt;&lt;span&gt;(snap, f, key, opts);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; def.value &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; def.&lt;/span&gt;&lt;span&gt;value&lt;/span&gt;&lt;span&gt;(rows) &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; rows.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A test checks this for every metric across date ranges and locations.&lt;/p&gt;
&lt;p&gt;The linkage number had the same flaw: it counted patients that pointed at some lead. The connect column holds only the surname, and the old join took the &lt;span&gt;first match&lt;/span&gt;, so 31.2 percent of patients resolved to an arbitrary lead. Breaking ties on first name cut that to 5.8 percent, and each row now records how it linked. No card moved, because the cards read lead fields copied onto each patient row.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A past nobody saved.&lt;/strong&gt; The pace line compares the cash on the books by today’s business day with the 12 month average at the same day. Monday keeps only the board as it is now, so the code rebuilds each past day backwards from the board and its activity log. Before it shipped, the rebuild matched 45 of 45 stored snapshot checks exactly: 5 export dates, 3 practices, 3 months each. Our finance lead chose what “on the books” means: the month as it stood at the end of that day, including cases that later fell through.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A test that could not see.&lt;/strong&gt; Before switching over, I ran the old Apps Script code against the same exports and diffed every metric. It matched and was still off by a day. The Excel reader returns a date cell as midnight UTC, which New York reads as the day before: 948 case acceptance dates on the Rochester board. Both sides used my reader, so parity could not see it. A golden test with figures from the production dashboard caught it.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Making every tab live.&lt;/strong&gt; Only cash flow reads Monday daily. Every column it needs is native to the patient boards. The other tabs mix in the Leads board, so they stay on the weekly exports.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Counting stale start dates as cash.&lt;/strong&gt; On the September 14 exports, the Rochester board had 356 accepted cases with no start date. 337 were accepted in 2024 or 2025, before the column was filled in reliably, and are finished treatments. Only the last 90 days of acceptances count as pipeline. The rest show as a data quality count.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Swapping numbers under someone.&lt;/strong&gt; A newer snapshot raises a banner. The numbers change when someone clicks it.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Nine Hybridge accounts can open it. Our finance lead files requests in its Feature requests tab. Both requests above went live within a day. I have not measured how often each person opens it.&lt;/p&gt;
&lt;p&gt;The billing export for its Google Cloud project only switched on Oct 28, 2026, so there is no measured month of cost yet.&lt;/p&gt;
&lt;p&gt;What I would keep: make the card count the rows behind it, and back a claim like “two tabs cannot disagree” with a test.&lt;/p&gt;</content:encoded></item><item><title>The finance forecast now builds itself by 6:25 every weekday</title><link>https://sampreethavvari.com/posts/daily-sales-report/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/daily-sales-report/</guid><description>The VP of Finance pasted two lab exports into a 14 tab workbook every morning and sent the forecast by hand. Now a pipeline builds the same forecast by 6:25 each weekday.</description><pubDate>Sat, 03 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Every morning our VP of Finance ran the same routine. Two exports from the dental lab’s case system landed in their inbox. They pasted one at cell A4 of a 14 tab workbook and the other onto another tab, recalculated, checked that the data quality block read &lt;span&gt;ALL CHECKS PASS&lt;/span&gt;, and sent the summary tab.&lt;/p&gt;
&lt;p&gt;The workbook projects invoiced lab revenue and new arch starts for this month and the next two, each with a band sized to hold about 68 percent of past outcomes. The VP built it and handed it over on August 25 with a 700 line spec and 15 acceptance values. One line sat above them: “If a rebuild does not reproduce them, the rebuild is wrong, not the spec.”&lt;/p&gt;
&lt;p&gt;The spec also listed two Excel bugs that had quietly returned wrong numbers. The job: take the person out of the morning and leave the model exactly as it was.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A weekday Cloud Run job reads the two exports, drops patient and practice fields as it parses them, loads BigQuery, and runs a pure Python port of the model. Nine checks decide whether the report goes out, and Gemini writes one paragraph over the finished numbers. A short Apps Script in the VP’s own Google account carries the exports in and the report out. A dashboard behind Google sign in shows the report as sent and every run with its steps.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/architecture/daily-sales-report&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first commit, at 2:18 PM on September 8, was the engine reproducing all 15 acceptance values. By 5:14 PM that day the daily orchestrator and a first dashboard were in.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;September 10: deployed behind Google sign in.&lt;/li&gt;
&lt;li&gt;September 24: the first report from the scheduled job, 16 days after the first commit.&lt;/li&gt;
&lt;li&gt;October 7: the VP asked for the report to fill in a missing number on its own. The fix and an alert were committed at 6:03 PM, and the next morning’s report was built at 6:22.&lt;/li&gt;
&lt;li&gt;October 9, 3:08 to 3:39 PM: all nine tabs and the emailed report restyled, one screen at a time.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Reading the lab reports without reading a mailbox.&lt;/strong&gt; My first design used the Gmail API with domain wide delegation. Delegation is granted to an identity, and that identity could have read any mailbox in the company. Instead, an Apps Script inside the VP’s own account asks Gmail for headers and the attachment layout only, and copies matching files to a bucket where that account can add files and do nothing else. The same script emails the finished report. Nothing in the project holds a credential that can read mail.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A number nobody typed.&lt;/strong&gt; Check 6 wants last month’s new arch starts, typed by hand from the P&amp;amp;L into a dashboard form. September’s was never typed, and a failed check holds the report, so four mornings from October 2 to 7 were built and held. The VP’s reaction: “Wait it already knows! Tell it to learn from itself.” The lab’s own starts report does count every arch. Now a closed month with no typed figure takes that count, once every row for the month comes from a pull made after it ended, and a typed figure still wins. An alert on the job’s own log line now emails me about any held or broken morning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gemini writes the paragraph, code checks every number.&lt;/strong&gt; Gemini’s only input is a payload of finished projections, bands and check results. Code checks that every number in the paragraph appears in that payload, in some reasonable rounding.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# packages/pipeline/dsr_pipeline/narrative.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;allowed &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; _allowed_numbers(payload)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; m &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; _NUM&lt;/span&gt;&lt;span&gt;.finditer(text):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    token &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; m.group().rstrip(&lt;/span&gt;&lt;span&gt;&quot;.&quot;&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; token &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; allowed &lt;/span&gt;&lt;span&gt;or&lt;/span&gt;&lt;span&gt; token.replace(&lt;/span&gt;&lt;span&gt;&quot;,&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; allowed:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        continue&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; &quot;states a number not present in the inputs: &lt;/span&gt;&lt;span&gt;%s&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; %&lt;/span&gt;&lt;span&gt; token&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The guard also rejects “starts lead revenue”, a claim the spec tested and found not significant, and any advice to retune. On an error, a timeout or a rejection, a deterministic template ships.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Letting the model tune itself.&lt;/strong&gt; Across a 1,280 combination grid, the spec’s best setting scored 2.59 percent on the months it was tuned on and did 0.62 points worse on months it had not seen. &lt;span&gt;315 days of forecasts&lt;/span&gt; share 15 monthly actuals. Nothing in the pipeline can write a constant. A refit script proposes changes once a year, and a person decides.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My own better model.&lt;/strong&gt; The spec lags arch starts by a whole month, though an arch’s invoices spread out, centered about 20 business days after the start. My version attached each start to the days that earn from it. Over 8 scorable months it improved next month’s error by 0.28 points and worsened this month’s by 0.29, both under the spec’s 0.3 point bar for calling a change real. Rerun on September 28 with July and August closed, the gain was gone. It sits behind a one line switch, off, until there are 18 scorable months.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retries.&lt;/strong&gt; The job has none. A crash leaves the landed files in place and the next 15 minute slot reads them again.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;The VP of Finance gets the report each weekday morning from their own account, and two named accounts can open the dashboard. 22 reports have been published since September 24, including 16 of 16 weekday mornings since the October 7 fix. A run that builds one averages 49 seconds.&lt;/p&gt;
&lt;p&gt;The accuracy is the VP’s work: the spec measured current month revenue error falling from 4.2 percent early in the month to 1.5 percent late. My part is keeping the 6:20 run on that same model, with the 15 acceptance values pinned in its tests.&lt;/p&gt;
&lt;p&gt;The billing export for this project only switched on October 28, 2026, so there is no measured month of cost yet. I never timed the old routine, so I claim no minutes saved. Whether the live bands hold 68 percent needs more closed months.&lt;/p&gt;
&lt;p&gt;What I would keep: the acceptance values as the first commit, and a language model that only restates numbers code has already checked.&lt;/p&gt;</content:encoded></item><item><title>A Claude connector for our marketing data, live in three hours</title><link>https://sampreethavvari.com/posts/data-connector/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/data-connector/</guid><description>Asking Claude about our marketing data took the Google Cloud SDK and a fresh login most days. I built a connector: sign in with Google once, see only the data an owner ticked.</description><pubDate>Tue, 27 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our marketing lead asks Claude questions about our &lt;a href=&quot;/posts/marketing-warehouse&quot;&gt;marketing warehouse&lt;/a&gt;. In October that took the Google Cloud SDK, two command line sign ins, a database tool on their laptop, a hand edited Claude config, and a fresh Google Cloud login most days, because our Workspace makes those logins expire. Every new person would repeat all of it. Someone outside the company could not do it at all.&lt;/p&gt;
&lt;p&gt;What I wanted was simple to say. Add a person by typing an email and ticking which data they can see. They connect Claude once, sign in with Google once, and stop thinking about logins.&lt;/p&gt;
&lt;p&gt;The first four days of real use showed a second problem. Of 227 queries, 15 failed, and 10 of those named a column that does not exist. The call log could show me SQL that failed. It could not show me a number that was wrong.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A remote MCP server on Cloud Run that Claude adds as a custom connector, on the web, the desktop app or a phone. An owner page lists the people and 13 boxes, one per data source plus patient names and the family practice and lab. Each set of boxes gets its own BigQuery dataset of views. Claude writes SQL against the warehouse’s own table names, and a guard points every table at that person’s dataset before anything runs. The connector’s instructions carry the business rules, including a Source section on every answer and a TapClicks flag on any number from the agency feed.&lt;/p&gt;
&lt;p&gt;Then an accuracy layer that moves the math to the server, and an eval that asks Claude 40 questions through the connector. &lt;a href=&quot;/architecture/data-connector&quot;&gt;Open the drawing&lt;/a&gt; to see what is live and what waits on main.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Oct 22, 2:35 PM:&lt;/strong&gt; the design. The build plan at 4:33 PM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Oct 23, 12:06 PM:&lt;/strong&gt; first code, the boxes and access sets. Then the SQL guard at 12:39 PM, the MCP server at 1:38 PM, the owner page at 2:37 PM, the deploy at 3:02 PM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3:06 PM:&lt;/strong&gt; the service is live, three hours after the first code. The call log has its first real question at 3:38 PM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Oct 27:&lt;/strong&gt; one box per data source at 3:48 PM, the accuracy layer at 6:02 PM, the eval results at 6:07 PM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Oct 28, 11:59 AM:&lt;/strong&gt; the accuracy layer goes live. I rolled it back the same day.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Oct 29:&lt;/strong&gt; CPU billed per request at 1:04 PM, Business Profile straight from Google at 1:34 PM, and a 6:20 AM job that builds everyone’s views after the morning pulls.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The ask that started it, the marketing lead’s setup, went from design to a live connector in a little over 24 hours.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Access that holds when Claude writes its own SQL.&lt;/strong&gt; Claude can write any query it likes, so the boxes cannot depend on instructions. Each set of boxes names its own dataset:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# src/data_connector/boxes.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;letters &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &quot;&quot;&lt;/span&gt;&lt;span&gt;.join(b.letter &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; b &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; BOXES&lt;/span&gt;&lt;span&gt; if&lt;/span&gt;&lt;span&gt; b.key &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; self&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; f&lt;/span&gt;&lt;span&gt;&quot;access_&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;letters&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; if&lt;/span&gt;&lt;span&gt; letters &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; None&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That dataset holds views authorized to read marts and staging. Without the patient box the contact columns are gone; without the family practice box those rows are filtered out. Queries run as one of two identities that can read only these datasets. The guard parses each query with sqlglot, refuses anything but one SELECT, and rewrites every table name. A warehouse table with no rule in the catalog stays hidden.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sign in once.&lt;/strong&gt; Claude connects over OAuth. FastMCP’s proxy sends the person to Google asking only for openid and email, so our Workspace’s Google Cloud reauthentication rule never applies. The connector issues its own tokens, 7 days for access and &lt;span&gt;10 years for refresh&lt;/span&gt;, and keeps the sessions encrypted in Firestore so a redeploy signs nobody out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Numbers you can trust.&lt;/strong&gt; The connector does the SQL and the math, and Claude phrases the result. &lt;code&gt;get_metric&lt;/code&gt; answers from 58 fixed metrics over 10 tables of facts. It turns “last month” into exact dates and says them back, takes totals from the same query with GROUPING SETS, and works out ratios from summed parts. Every result carries Source lines, missing days and the TapClicks flag. &lt;code&gt;find_values&lt;/code&gt; shows what a column really holds.&lt;/p&gt;
&lt;p&gt;The eval asks the 40 golden questions through the Claude CLI, which can see only the connector. Each answer has to be right and grounded: every number in it must appear in a tool result. Run 1 passed 38 of 40, with all 40 answers correct; twice Claude &lt;span&gt;added rows itself&lt;/span&gt;. I added column totals. Run 2 passed 39 of 40. The three trick questions, about an office that is not ours, radio spend and Meta in January 2026, all came back “not in the warehouse” with no number.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Hard stops for quality.&lt;/strong&gt; Security is a hard block. Every quality check is a note next to the result and never stops a query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Office limits.&lt;/strong&gt; I left office level limits out of the design. Access goes by data source.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Materializing the heavy marts.&lt;/strong&gt; The lead and funnel marts scan about 2 GB a query. A scheduled table would make them faster and add a refresh that can fail, so they stay views.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Three people are on the list: our marketing lead, a marketing teammate and me. From October 23 to 29 they made 469 tool calls on six days, 364 of them queries.&lt;/p&gt;
&lt;p&gt;The live service runs the October 27 code plus direct Business Profile data, on one warm instance with CPU billed only while a request runs. Main is ahead of what is live: it has the accuracy layer.&lt;/p&gt;
&lt;p&gt;Cost is not measured yet. The billing export for this project switched on October 28, 2026, so there is no measured month. Time saved is not measured either.&lt;/p&gt;
&lt;p&gt;What I would keep: the server does the math and carries the caveats, and Claude says it in plain English.&lt;/p&gt;</content:encoded></item><item><title>Our Denticon mirror went from six days behind to under three minutes</title><link>https://sampreethavvari.com/posts/denticon-sync/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/denticon-sync/</guid><description>Four Hybridge services need Denticon, and only one may hold its keys. I built the sync that keeps our copy fresh: webhooks first, a nightly poll behind them, and a check that notices silence.</description><pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Denticon, from Planet DDS, holds every chart and appointment at our three offices. The CDF’s patient list, the treatment estimator, the capacity planner and the report renderer all need what is in it.&lt;/p&gt;
&lt;p&gt;Two things made a direct read a bad idea. Denticon’s API has no patient search, so a copy of our own is the only patient index we have. And I had decided that only one service would ever hold Denticon credentials. Until September 21 those credentials sat on the CDF dashboard, a service that also serves pages to people all day.&lt;/p&gt;
&lt;p&gt;The copy itself was going stale. My handoff measured it 24.9 hours behind Denticon on September 16 and 6.0 days behind on September 21, because nothing ran the sync on a schedule.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A sync service: the CDF’s own image, deployed as a second Cloud Run service with one warm instance and four at most, holding the only Denticon credentials.&lt;/p&gt;
&lt;p&gt;Webhooks come first. When records change, Denticon posts a pointer to a slice; the receiver saves it and answers within Denticon’s ten second window. A worker every ten minutes claims pointers, fetches each slice with credentials, and upserts the rows into Firestore by record id. A sweep at twenty past each hour lists any slice a webhook missed, and a 4 AM poll reads everything changed since each office’s cursor. &lt;a href=&quot;/architecture/denticon-sync&quot;&gt;Open the drawing&lt;/a&gt;, and &lt;a href=&quot;/architecture/cdf-registry&quot;&gt;the registry’s drawing&lt;/a&gt; for how other apps read the mirror.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first Denticon commit went in at 12:36 AM on September 15, a token client and the office table. By 2:44 AM the API reference was transcribed from the vendor portal and tested against staging. The patient and appointment mirror was backfilled that afternoon.&lt;/p&gt;
&lt;p&gt;On September 21 the sync service was deployed at 12:48 PM. At 1:33 PM I committed &lt;span&gt;“Putting the sync on a timer found four reasons it had never worked”&lt;/span&gt;. The same day the mirror trailed Denticon by 2.6 minutes.&lt;/p&gt;
&lt;p&gt;Planet DDS turned on our production integration on September 28. At 2:57 AM on September 29 the sync read the live practice, and the backfill wrote 20,111 charts and 22,688 appointments. At 5:24 AM I fixed the two faults the first real webhooks found.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Four reasons it had never worked.&lt;/strong&gt; The nightly job got a redirect to the sign in page, because its route was missing from the middleware’s allow list, while Cloud Scheduler recorded a success. The route took a session and nothing else. It synced offices and providers and never patients or appointments. And every bearer secret carried a trailing carriage return from Windows, so the freshness check refused its own secret. The shared check now trims both sides:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// lib/bearer.ts&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;expected) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; false&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; want&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; Buffer.&lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt;(expected.&lt;/span&gt;&lt;span&gt;trim&lt;/span&gt;&lt;span&gt;());&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Green while every call failed.&lt;/strong&gt; On its first scheduled hour the slice sweep answered ok with all eighteen of its vendor calls failing, before the subscriptions existed. Its log line read exactly like a healthy idle sweep, so it now names its errors. The freshness check starts from the twelve cursors it expects and reports a missing one as the worst status.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Production did not match the reference.&lt;/strong&gt; The live slice file wraps records in a PascalCase envelope, so the worker read every slice of September 29 as zero rows, and the receiver refused real notifications. It reads both shapes now.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A one minute poller.&lt;/strong&gt; Webhooks carry the load and the 4 AM poll is the safety net.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Trusting a webhook.&lt;/strong&gt; A notification is a pointer and nothing in it is stored as data. The worker always fetches the slice itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deleting what Denticon deletes.&lt;/strong&gt; A Delete slice marks the mirror row as deleted upstream, so a record that still points at the chart can see what happened.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Four services read the mirror and none holds a Denticon credential: the CDF’s patient list, the estimator’s day through the registry, the capacity planner every minute through the registry, and the report renderer before it emails a patient.&lt;/p&gt;
&lt;p&gt;Its cost is not measured: the billing export for its project only switched on Oct 28, 2026, so there is no measured month yet.&lt;/p&gt;
&lt;p&gt;What I would keep: run the scheduled job by hand on day one and read what it actually did.&lt;/p&gt;</content:encoded></item><item><title>From top priority to a live doctor portal in under four hours</title><link>https://sampreethavvari.com/posts/doctor-portal/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/doctor-portal/</guid><description>Doctors who scanned, then sent the Rx, ended up with two lab cases and got scared when we cancelled one. I built a portal where the Rx joins the scan&apos;s case. Early, on sample data.</description><pubDate>Wed, 28 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our dental lab makes the teeth for implant cases, and more than 200 outside doctors send it work. A doctor scans the patient, and the scanner opens a case in MagicTouch, the lab’s case system. Then the doctor writes the prescription in MagicTouch’s own doctor portal, which cannot edit a case that already exists. So the Rx opens a second case.&lt;/p&gt;
&lt;p&gt;The lab knew which one to keep. The doctor did not. In our lab lead’s words, the doctors “see us cancel one and then they get freaked out.” The lab lead also said the old portal had “a few workarounds that are confusing to our team,” and that doctors reached the lab by email, text, phone and MagicTouch. The ask was one place for all of it.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A doctor portal built from the lab lead’s own July mockup: Submit Rx, Cases with approvals and messages, Accounting, Resources and Account. The core move is simple. The doctor sends the scan from the scanner, then adds the Rx to that same case here.&lt;/p&gt;
&lt;p&gt;It is early. Everything runs on one made up practice, and only Hybridge Google accounts can sign in. A switch in the bar flips between the doctor’s view and the lab’s, which stands in for MagicTouch until a real link exists. &lt;a href=&quot;/architecture/doctor-portal&quot;&gt;Open the drawing&lt;/a&gt; to see what is live and what is planned.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The repo started at 2:12 AM on September 29 with a different idea: a morning list of duplicate cases for customer service to merge. It was deployed on sample data that night, last fix at &lt;span&gt;12:51 AM&lt;/span&gt;. At the 11:31 AM sync that day, the lab lead turned it down.&lt;/p&gt;
&lt;p&gt;Then the portal sat for four weeks behind other work.&lt;/p&gt;
&lt;p&gt;On October 28, at the 11:30 AM sync, the lab lead made it the top priority and asked for a live link, starting at version 0.1.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;12:59 PM: v0.1, most of the lab lead’s mockup on sample data.&lt;/li&gt;
&lt;li&gt;2:20 PM: v0.2. It worked but read like a spreadsheet, so the tables came out.&lt;/li&gt;
&lt;li&gt;3:00 PM: drag came back. In July the lab lead had asked to arrange the case view their own way and keep it.&lt;/li&gt;
&lt;li&gt;3:03 PM: first revision live. It failed its first visit.&lt;/li&gt;
&lt;li&gt;3:12 PM: 0.2.1 live and working, with a version pill and a What’s new page. 3.7 hours after the sync started.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;v0.3 added a Feature requests tab the next afternoon. I deploy by hand from main, so no CI minutes are spent.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;One case rests on a question nobody has answered.&lt;/strong&gt; In the portal, an Rx sent against a scan case joins it, and the send refuses a case that already has an Rx. That part is code. Whether MagicTouch’s API can add an Rx and files to a case the scanner created is not. The vendor says one API only sees its own cases and the other has no file endpoints. It is the first question for a vendor call planned for early October that has not happened.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My laptop lied to me.&lt;/strong&gt; On my laptop the portal keeps documents in memory. Deployed, they go to Firestore, which refuses an array directly inside an array, and an Rx keeps each answer as a question and answer pair. The memory store never complained, so the first revision died on its first visit. The fix wraps inner arrays on the way in and unwraps them on the way out:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// apps/portal/lib/store/firestoreCodec.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (Array.&lt;/span&gt;&lt;span&gt;isArray&lt;/span&gt;&lt;span&gt;(value)) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; items&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; value.&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; toFirestore&lt;/span&gt;&lt;span&gt;(v, &lt;/span&gt;&lt;span&gt;true&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; insideArray &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; { [&lt;/span&gt;&lt;span&gt;WRAP&lt;/span&gt;&lt;span&gt;]: items } &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; items;&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;Now a live round trip test, and a script that fills the store before anyone visits, catch it first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Colour that answers one question.&lt;/strong&gt; Whose move is it? Amber is yours, blue is the other side, green is on its way, red is stuck. One function decides it for both views, so a case waiting on approval is amber for the doctor and blue for the lab. Work sits in lanes of equal width, with a written rule against an &lt;span&gt;orphan card&lt;/span&gt;.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Merging duplicates after the fact.&lt;/strong&gt; That was the September build. The lab lead said no, and was right: a merge on our side still leaves the doctor watching a case get cancelled. I parked it instead of polishing it, and kept its MagicTouch code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pretending the link exists.&lt;/strong&gt; Every release note ends with the same four lines: sample data only, files listed by name only, payments and emails off, doctors cannot sign in yet. A feed of upload emails from the old portal, 1,664 since March 27, would make the demo feel alive. It carries patient names, so it stays out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Doctor logins and a hole in the lab’s network on day one.&lt;/strong&gt; Outside doctors will need their own sign in, after the core flow works on real cases. For lab data, the plan is a small connector on a lab machine that only sends changes out over HTTPS, so no port opens into the lab network.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It has no real users yet. It is a live link on sample data for the lab lead to pick apart. I have no usage numbers.&lt;/p&gt;
&lt;p&gt;The Google Cloud project it shares with the parked duplicate list cost six cents from October 1 to 28. The portal went live on the 28th, so its own month is not measured yet.&lt;/p&gt;
&lt;p&gt;Real data waits on the Digital Gateway license at $126 a month, not bought; the vendor call; and the lab server’s details and a read only login. File uploads to Cloud Storage are next and need none of them.&lt;/p&gt;
&lt;p&gt;What I would keep: put the lab lead’s own mockup on a live link the day it becomes the priority, and write down on every release what is still pretend.&lt;/p&gt;</content:encoded></item><item><title>Monday report cards for every doctor, with nobody pressing send</title><link>https://sampreethavvari.com/posts/doctor-report-cards/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/doctor-report-cards/</guid><description>Every consult already had a score. Now Gemini reads them all and writes each doctor a report card, every Monday at 8 AM and on the first weekday of each month.</description><pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;By June every consult already had a scorecard. That was the trouble. A doctor got one report email per consult, and the questions that matter, how is this doctor doing lately and how do they compare, still meant scrolling the master Sheet and squinting.&lt;/p&gt;
&lt;p&gt;The goal, in the words of the feature doc, was to keep every consult to the same bar regardless of who runs it or when. That needs a view across consults and across doctors, written so a clinician reads it as coaching. The comparison prompt says it in one line: &lt;span&gt;the reader is a clinician, not a defendant&lt;/span&gt;.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Analyse, a second pipeline on top of the same ledger. Pick doctors and a range of 7, 14, 30, 60 or 90 days, or your own dates. It reads the per consult summaries already stored in the master Sheet, plus each doctor’s best and worst scoring transcript for concrete moments. Then one Gemini call per doctor and one for the comparison.&lt;/p&gt;
&lt;p&gt;The report has a cover, one page per doctor with strengths and things to work on across the same 7 categories the consult grader uses, a comparison page with what top performers do and what pulls scores down, 3 green and 3 red examples, and a weekly trend chart. A doctor with fewer than 3 consults in range gets a “limited data, directional only” label. It is only complete when the PDF is rendered and the Sheet is synced; email comes after.&lt;/p&gt;
&lt;p&gt;It also runs itself. Every Monday at 8 AM it covers the previous Monday to Sunday, and on the first weekday of each month the whole previous month. &lt;a href=&quot;/architecture/doc-coach&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;June 8. The design spec went in at 2:32 PM. By 3:33 PM it had prompts and schemas, the ledger reader, the Gemini generator, charts, the renderer, the job store, the orchestrator, the API, a retry sweep for failed reports, the UI, an end to end test and the docs. At 4:17 PM came the fix for the first real run, and at 4:39 PM the email that carries the whole report.&lt;/p&gt;
&lt;p&gt;June 9 at 1:30 PM the weekly and monthly schedules shipped.&lt;/p&gt;
&lt;p&gt;Then a Monday report did not arrive, and someone noticed. A single network blip at 8 AM had claimed that week’s marker before the ledger read failed, so the scheduler thought the week was done. The fix released the marker whenever an attempt fails before a report exists, so the next tick five minutes later tries again.&lt;/p&gt;
&lt;p&gt;On September 14 the recipient list grew from two people to the CEO, the operations group and every doctor with at least one consult in the period. Doctors see each other’s scores on purpose. A doctor with no consults in the window is left off.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Strict where it feeds a database, forgiving where it feeds a page.&lt;/strong&gt; I copied the consult grader’s discipline: validate the JSON against a schema, retry once with a hardened prompt, then stop. For report cards that was too strict. The model failed validation twice and the first real run quit at 20 percent. The scorecard schema stays strict because every field becomes a column in the ledger. The comparison schema went from four required sections, closed objects and length caps to one required headline and plain types. A worked example in the prompt now drives the quality, and the renderer reads every field with a default. The schema’s own description says it: forgiving on purpose.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;8 AM, exactly once.&lt;/strong&gt; There is no Cloud Scheduler job here. A loop inside the service wakes every 5 minutes and fires when it is past 8 AM in New York and the period is not yet claimed. The claim is an atomic Firestore marker, so two instances or a restart cannot send the week twice, and a deploy over 8:00 just means it fires a few minutes late. The slim container image also had &lt;span&gt;no timezone data&lt;/span&gt;, so asking for New York time would have crashed. tzdata went into the image before the first Monday.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Examples that are real.&lt;/strong&gt; A comparison is only useful if its examples happened. Each report pulls at most two transcripts per doctor, their best and worst by percent, and the prompt asks for exactly 3 good and 3 bad moments, each with the doctor, the quote and the lesson. When outcomes from the case boards are available, it may compare conversion, but only with the numbers it was handed. The prompt says never invent or recompute a statistic.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The radar chart.&lt;/strong&gt; I built one at 3:00 PM and removed it at 4:39 PM the same day. It looked bad, so it went. The weekly trend line stayed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Watching the videos again.&lt;/strong&gt; Analyse reads the stored summaries and at most two transcripts a doctor. The expensive reading already happened at consult time, and a report card should not pay for it twice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pages about nobody.&lt;/strong&gt; A doctor with zero consults in the window gets no page and no email, and a period with no consults at all is skipped quietly. Its marker is still claimed, so the scheduler does not check again every five minutes all day.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;The weekly report lands Monday at 8 AM and the monthly one on the first weekday, for the CEO, the operations group and the doctors who had consults. Anyone can also run one from the Analytics tab for any doctors and range, and every run is saved with its PDF in Drive and rows in two Sheet tabs.&lt;/p&gt;
&lt;p&gt;Since October 29 the instance stays warm 7 AM to 7 PM, and 8 AM sits inside that window. Cost is not measured yet: the billing export for this project only switched on October 28, 2026.&lt;/p&gt;
&lt;p&gt;I have not pulled a count of reports sent, and I do not have a measure of whether report cards changed how doctors run consults. Both are open.&lt;/p&gt;
&lt;p&gt;What I would keep: let the strictness of a schema follow where its output goes. A database row needs a contract. A page of coaching needs a good example.&lt;/p&gt;</content:encoded></item><item><title>A company search that cites its sources or says it does not know</title><link>https://sampreethavvari.com/posts/enterprise-search/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/enterprise-search/</guid><description>Hybridge staff could ask a question and get an answer cited to the exact passage, filtered by who may see what. Built in a month, paused since October.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Hybridge runs on documents spread across shared drives, slide decks, SOPs and a few people’s heads. When someone needs an answer, they ask around, or they find a document and hope it is the current one.&lt;/p&gt;
&lt;p&gt;A search box over all of it is harder than it sounds. Patient details, HR records and business plans sit side by side, so the search has to know who may see what before it looks. Documents disagree, like an old schedule nobody deleted next to the new one. And a confident wrong answer is worse than none, because people act on it.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Ask Hybridge is a chat. Anyone with a company Google account signs in, asks a question, and gets an answer streamed back with numbered citations. Click one and the source opens beside the chat, scrolled to the highlighted passage.&lt;/p&gt;
&lt;p&gt;Behind it, a FastAPI service on Cloud Run searches Postgres two ways inside the asker’s group permissions: by meaning with pgvector and by exact words with full text search. Vertex AI reranks the merged list down to 8 passages. Gemini 2.5 Flash answers from those 8, cites every claim, and otherwise replies “I don’t have that in the knowledge base.” If the asker can see no passages at all, the API sends that line without calling the model.&lt;/p&gt;
&lt;p&gt;Dropoff is the second tab, for uploaders. You drop a file and set who can see it, its class and its effective date. The API hashes it, skips duplicates, keeps the original in Cloud Storage, has Gemini write a short card, then chunks it with character offsets, embeds it and indexes it. &lt;a href=&quot;/architecture/enterprise-search&quot;&gt;Open the drawing&lt;/a&gt; to see every part.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;I wrote the retrieval research and the design on June 22, then built it in five stages, each with its own spec, plan and tests. June 23 was the busiest day: sign in, roles and audit logs after midnight, then Terraform and the Cloud Build pipeline, then the whole ingestion pipeline that evening. The last commit of that night landed at &lt;span&gt;3:44 AM&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;On June 27 the read path went in between 10:42 and 11:37 PM: the permission filter, both searches, rank fusion, the reranker, the conflict rules, the generator and a small eval test. It took 55 minutes, against a plan written four days earlier. The chat front end followed on June 30 and the Dropoff front end on July 1. On July 16 I wired the real models into ingestion and wrote a demo guide and a slide deck. The last merge was July 23.&lt;/p&gt;
&lt;p&gt;From the first commit to real models in production took 24 days. Much of the work happened after 10 PM. The deploy pipeline runs both test suites before it migrates the schema and ships the API and the web app.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Permissions before ranking.&lt;/strong&gt; Every chunk carries the groups of its document. The filter is a SQL condition inside both searches, next to a clause that keeps only current documents, so a restricted passage never becomes a candidate. It never reaches the reranker or the model, so no prompt can talk it out.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# api/app/serving/acl.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;acl &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; Chunk.group_acl &lt;/span&gt;&lt;span&gt;==&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; caller_groups:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    acl &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; acl &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; Chunk.group_acl.op(&lt;/span&gt;&lt;span&gt;&quot;&amp;amp;&amp;amp;&quot;&lt;/span&gt;&lt;span&gt;)(&lt;/span&gt;&lt;span&gt;list&lt;/span&gt;&lt;span&gt;(caller_groups))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;clauses.append(acl)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the demo, a leadership document exists only for members of the leadership group.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When two documents disagree, rules decide.&lt;/strong&gt; Each document gets an authority score from its class, its uploader’s role and its source. Superseded versions drop out, and the rest sort by authority, then effective date. If the top two are within one point and dated differently, the API adds a conflict note that Gemini must address and the chat shows in a callout. The demo set has two office hours schedules that disagree, &lt;span&gt;Thursday at 3 PM and Friday at 11 AM&lt;/span&gt;. The newer one wins and both are cited.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The breakages were plumbing.&lt;/strong&gt; The bug that blocked every sign in was a Google SDK that had to be set up before its first token check; the fix went in at 11:57 PM on June 30. Later, the reranking library was missing from the image for a week, and the wrapper around the reranker fell back to the fused order and logged why.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A separate vector database.&lt;/strong&gt; At hundreds to low thousands of documents, one Postgres on Cloud SQL holds vectors, the keyword index, permissions, versions and chats under one filter. My research before the first line of code found no speed reason for a second store at that size.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GraphRAG and vectorless trees.&lt;/strong&gt; In the benchmarks I checked, graph retrieval lost to vector search with reranking on simple fact lookups and used far more prompt tokens. Vectorless page trees had no verified benchmark and no managed option on Google Cloud. Both stay off until a real question needs several hops.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An agent loop on every question.&lt;/strong&gt; The design put a corrective loop (plan again, search again, check the draft) behind a confidence check. Nothing ever measured a need for it, so I never built it.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It ran on Google Cloud from June 23, and anyone at Hybridge with a company Google account could sign in. I built a demo set of 13 short documents about Hybridge projects, with a conflicting pair and a restricted one. The &lt;a href=&quot;/enterprise-search/demo-guide.html&quot;&gt;demo guide&lt;/a&gt; walks each stage, and the &lt;a href=&quot;/enterprise-search/slides.html&quot;&gt;slides&lt;/a&gt; are the short version.&lt;/p&gt;
&lt;p&gt;It is paused. Higher priority tools came first after July 23, and billing for its Google Cloud project has been off since October 8, 2026, so the database is suspended and the chat does not answer. I have no usage numbers. No billing export ever covered the project, so its cost was never measured.&lt;/p&gt;
&lt;p&gt;Retrieval quality on real staff questions was not measured either. A seeded test fails the build if recall at 8 drops below 0.8 or mean reciprocal rank below 0.35, but it runs on test models and a fixed set, so it guards the wiring.&lt;/p&gt;
&lt;p&gt;What I would keep: the permission filter inside the SQL, before anything ranks or reads a passage.&lt;/p&gt;</content:encoded></item><item><title>Labeling 2020 election tweets with a 4 bit Llama 3 on one GPU</title><link>https://sampreethavvari.com/posts/fake-news-sentiment/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/fake-news-sentiment/</guid><description>A three person NYU project on the 2020 election: a fine tuned Llama 3 labels tweets true, false or opinion, RoBERTa reads their mood, and I turned the results into state maps.</description><pubDate>Sun, 12 May 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our NLP course project took on a question people argued about all through 2020: how much of the election talk on Twitter was false, and how did people feel about each candidate? The data was a public set of about 1.7 million tweets tagged #JoeBiden or #DonaldTrump, from October 15 to November 8, 2020.&lt;/p&gt;
&lt;p&gt;”Detect fake news” hides two jobs. One asks whether a claim is true. The other asks how a crowd feels. They need different models and different data.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;We were a team of three. Teammates fine tuned the claim model: Llama 3 8B in 4 bit with a QLoRA adapter, trained on LIAR, a benchmark of political claims rated by PolitiFact editors. It labels each tweet True, False or Personal. My part was the mood side and the results: a RoBERTa sentiment pass, the state maps and swing state charts in Power BI, and the write up. &lt;a href=&quot;/architecture/fake-news-sentiment&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first commit went in at 9:45 PM on May 8, 2024, and the claim model notebooks followed at 12:13 AM. Most of my work landed on May 11. The final report went up at 10:36 PM on May 12.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Six labels down to two.&lt;/strong&gt; LIAR rates claims on six steps from true to &lt;span&gt;pants on fire&lt;/span&gt;. We collapsed them: true and mostly true became true; false, barely true and pants on fire became false; half true was split at random between the two. Training ended with 5,545 false and 4,695 true claims. QLoRA froze the 4 bit base and trained 83.9 million adapter parameters, which fit on one 32 GB V100. Accuracy on LIAR’s test claims was 76 percent.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A one token answer.&lt;/strong&gt; The classifier generates exactly &lt;span&gt;one token&lt;/span&gt; per tweet. That is cheap, and it is also all you get. One prompt version used markdown headings with four hash marks, and on that run 2,945 answers came back as ”####“. A plain text version of the same examples left 5 stray answers in 156,123. My read is that the model copied the prompt’s own formatting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The same tweet under both hashtags.&lt;/strong&gt; After cleaning, 315,020 tweets in the #DonaldTrump file also appeared in the #JoeBiden file. Kept, each would count once for each candidate, so they were dropped from both:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# Fake_News_Detection/fake_news_detection_inference.ipynb&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;common_texts &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; trump_final[trump_final[&lt;/span&gt;&lt;span&gt;&apos;tweet&apos;&lt;/span&gt;&lt;span&gt;].isin(joe_final[&lt;/span&gt;&lt;span&gt;&apos;tweet&apos;&lt;/span&gt;&lt;span&gt;])][&lt;/span&gt;&lt;span&gt;&apos;tweet&apos;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;trump_final_cleaned &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; trump_final[&lt;/span&gt;&lt;span&gt;~&lt;/span&gt;&lt;span&gt;trump_final[&lt;/span&gt;&lt;span&gt;&apos;tweet&apos;&lt;/span&gt;&lt;span&gt;].isin(common_texts)]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;joe_final_cleaned &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; joe_final[&lt;/span&gt;&lt;span&gt;~&lt;/span&gt;&lt;span&gt;joe_final[&lt;/span&gt;&lt;span&gt;&apos;tweet&apos;&lt;/span&gt;&lt;span&gt;].isin(common_texts)]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What I learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Constrain the output, then count the misses.&lt;/strong&gt; With one free token, anything can come back. Next time I would restrict decoding to the allowed labels and report how many answers fell outside them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A benchmark score is not a score on your data.&lt;/strong&gt; The 76 percent is on LIAR claims. The tweets have no answer key, and our report says so: the tweet labels are the model’s judgment, not checked facts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mood is not stance.&lt;/strong&gt; RoBERTa says whether a tweet is positive or negative, not whom it supports. Our report read a positive #JoeBiden tweet as pro Biden and named that as an assumption. A negative tweet under #JoeBiden can just as easily be an attack on the other candidate.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;A class project, not running. My share is in the repo: the sentiment notebook, the Power BI file with the state and swing state views, and the report. The model labeled about 32 percent of tweets false overall, 36 percent of negative tweets and 18 percent of positive ones. Those are model labels, not verified claims.&lt;/p&gt;
&lt;p&gt;What I would keep: split a fuzzy goal into the jobs inside it, give each job its own model, and say plainly which numbers were measured and which were inferred.&lt;/p&gt;
&lt;p&gt;Full project: &lt;a href=&quot;https://github.com/SampreethAvvari/us2020electionanalysis&quot;&gt;Fake News and Sentiment Analysis&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>One relay lets six apps file feature requests into one list</title><link>https://sampreethavvari.com/posts/feature-requests/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/feature-requests/</guid><description>Change requests for our apps arrived in chats and in passing. I built one request screen six apps share, and a relay that trusts each app only to say who is asking.</description><pubDate>Fri, 23 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;People who use my apps want changes. My design note from October 22 says those asks “arrive in chats and in passing, with no screenshot, no ID and no record of what happened to them.”&lt;/p&gt;
&lt;p&gt;I wanted one list for every app, inside Launchpad, the page staff already use to open them. The catch was the door. Launchpad sits behind IAP, and in June IAP on Cloud Run had refused a scripted service account token from my old leadership board. Six apps needed to file requests server side, each on its own Google Cloud project.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A relay. It is Launchpad’s own image deployed a second time as its own Cloud Run service, with relay mode on and no IAP. In relay mode it answers only the request API and a health check, and Cloud Run lets in only six app service accounts.&lt;/p&gt;
&lt;p&gt;Each app serves the same request screen, one custom element, and forwards every call from its own server with a Google ID token for its own account and the person its own sign in already checked. Requests get an FR number that never changes, land in Launchpad’s Firestore, and ping one Google Chat space. &lt;a href=&quot;/architecture/feature-requests&quot;&gt;Open the drawing&lt;/a&gt;, and &lt;a href=&quot;/architecture/launchpad&quot;&gt;Launchpad’s drawing&lt;/a&gt; for the list it fills.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;October 23 started at 1:19 PM. The screen as one element and the relay were in by 2:48 PM. By 3:16 PM the tab was merged into the estimator, the capacity planner, the CDF dashboard, Hybridge Forms and the Cowork Dashboard. The relay went live at 3:24 PM, and &lt;span&gt;FR-0001&lt;/span&gt; arrived at 3:32 PM. At 4:19 PM every app could delete its own requests.&lt;/p&gt;
&lt;p&gt;The Chat ping went live on October 27. The Doctor Portal became the sixth caller on October 29.&lt;/p&gt;
&lt;p&gt;At 11:44 AM on October 28 our VP of finance filed FR-0004 from the Cowork Dashboard: Rest of month on the cash flow view should include today. The fix was committed at 11:57 AM and live at 12:03 PM.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Trust in layers.&lt;/strong&gt; Cloud Run refuses any caller without invoker rights. Then the relay checks the token’s issuer and audience, maps the calling account to its app, requires the app header to name that same app, and requires a hybridgeimplants.com person. Every check must pass before it believes a name.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Each app forwards its own way.&lt;/strong&gt; The estimator drops any Authorization, Cookie or X-Relay header a browser sent and passes on only three content headers, with a token from the metadata server. The planner uses plain fetch with no Google client library. The Cowork Dashboard streams an upload of up to 25 MB straight through. The CDF refuses a write from another site, so a page elsewhere cannot post as a signed in coordinator.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An app reaches only its own requests.&lt;/strong&gt; A request filed through an app is about that app, and a delete through an app is checked inside the same transaction that writes it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// lib/requests/store/index.ts&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;live&lt;/span&gt;&lt;span&gt;(current) &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; (actor.app &lt;/span&gt;&lt;span&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span&gt; current.app &lt;/span&gt;&lt;span&gt;!==&lt;/span&gt;&lt;span&gt; actor.app)) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; notFound&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;FR numbers come from a counter read inside that transaction, only for a new request, so two requests never share a number and follow ups never wait on each other.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Admin through an app.&lt;/strong&gt; An app only vouches for who is asking. Statuses, heading edits and Copy stay in Launchpad, so a bug in one app cannot hand them out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A second build.&lt;/strong&gt; The relay deploy reuses the image the main deploy just built, so the board and the relay always run the same code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A shared secret.&lt;/strong&gt; Each app proves itself with its own service account’s token. There is no key to copy between projects or rotate in six places.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Six apps file through it: the treatment estimator, the capacity planner, the CDF Patient Dashboard, Hybridge Forms, the Cowork Dashboard and the Doctor Portal. The counter reached FR-0006 on October 29.&lt;/p&gt;
&lt;p&gt;The whole Launchpad project cost $0.32 from October 1 to 28. Request time for Launchpad, the relay and the retired June board together came to under two cents. Doc Coach and NPC Coach do not have the tab yet.&lt;/p&gt;
&lt;p&gt;What I would keep: let each app check its own people, and let the relay check only the app.&lt;/p&gt;</content:encoded></item><item><title>How making movies made me a better engineer</title><link>https://sampreethavvari.com/posts/film-and-engineering/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/film-and-engineering/</guid><description>People keep treating films and engineering as separate crafts. What making movies since 2019 did to the way I build software, from blocking a scene to cutting a feature.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I make movies. I write screenplays, direct, edit, and 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;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;Solstice&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;Swechcha&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 and an editor. Each one speaks a different vocabulary and defines “done” differently. The director’s job is to translate the scene into the eight languages on set.&lt;/p&gt;
&lt;p&gt;It’s the same room I walk into at work. A treatment coordinator who has quoted full arch implant cases for years doesn’t care how I store a frozen price. They care whether the system gives them the same number they quoted last month. The controller doesn’t care about my retry decorator. They care whether the bank file lands in the cash flow sheet before the 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 decision record before I touch the schema.&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;p&gt;&lt;strong&gt;Listen to the &lt;span&gt;script supervisor&lt;/span&gt;.&lt;/strong&gt; Lowest credit on the call sheet, highest practical authority on the day. At work that’s the person who does the job every day. Someone on the accounting team told me the bank import was grabbing half downloaded files, and that is why the import now stops and asks.&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 removing, and the cuts that hurt are usually the right ones. Code is the same. 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; My first rough cut goes to someone from the audience. I don’t ask for notes. I watch them watch it. The first demo of a feature goes to the person who will use it every day, and I would rather watch them fumble through the tooth chart in silence than read a 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 get judged on whether the scene works. A polished sequence of bad scenes is a polished bad film. I’m relentless about whether the shipped thing 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;&lt;strong&gt;The systems I ship survive contact with non engineers.&lt;/strong&gt; The odd rules in the dashboard, the estimator and the consult pipeline came out of arguments with the people who use them.&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 also the writer, the editor, the colourist, the producer’s bad cop and the actor’s good cop. I came to engineering already used to wearing eight hats.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The postmortem is part of the work.&lt;/strong&gt; I rewatch every film a year later with a list of what didn’t work and why. My systems keep a backlog of deferred items with the trigger to revisit each one. Same instinct: name the loose ends out loud.&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. Treat every project like a small film: a spec before the schema, department heads who all get heard, a scene that has to work, and 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>What a forward deployed engineer actually does, at a dental practice</title><link>https://sampreethavvari.com/posts/forward-deployed-engineer/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/forward-deployed-engineer/</guid><description>A forward deployed engineer works beside the people who have a problem and ships the fix into their systems. Here is that job at a three office dental practice, each example linked.</description><pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;What does a forward deployed engineer do?&lt;/h2&gt;
&lt;p&gt;A forward deployed engineer works beside the people who have a problem, learns how their work actually runs, and ships software that fixes it inside the systems they already use. The job is measured by whether those people use what ships, and how soon it reaches them.&lt;/p&gt;
&lt;p&gt;I do this job at Hybridge, a dental implant practice with offices in Rochester, Buffalo and Syracuse and its own lab. The work starts from a person. Our operations lead had a workbook. Our lab lead had a mockup. Our finance lead typed one line into a request tab. Each became a live system, and each example below links to its write up or its drawing.&lt;/p&gt;
&lt;h2&gt;How is a forward deployed engineer different from a software engineer?&lt;/h2&gt;
&lt;p&gt;Both write production code and both own its quality. The difference is where the work starts and where it ends.&lt;/p&gt;
&lt;p&gt;A software engineer on a product team usually starts from a ticket and a spec someone else wrote. A forward deployed engineer starts from a person and a messy process, and writes the spec themselves. On September 11 I took our operations lead’s planning workbook apart cell by cell, wrote the spec, and sent eleven questions. Each question already had a default coded, so nothing waited on an answer. The &lt;a href=&quot;/posts/capacity-planner&quot;&gt;capacity planner&lt;/a&gt; was in production 17 days later.&lt;/p&gt;
&lt;p&gt;The other end is after launch. The people who asked are the ones who find the edge cases. For &lt;a href=&quot;/posts/reconciliation&quot;&gt;Reconciliation&lt;/a&gt;, reviewers sent back patients the tool got wrong, and I traced each one, fixed the reading, added a test and shipped. When a practice director wrote &lt;span&gt;“I need an undo button!”&lt;/span&gt; in the planner, it shipped the same day.&lt;/p&gt;
&lt;h2&gt;What does the work look like day to day?&lt;/h2&gt;
&lt;p&gt;Five asks from five teams, and the time each took to go live:&lt;/p&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Who had the problem&lt;/th&gt;&lt;th&gt;What they had&lt;/th&gt;&lt;th&gt;What shipped&lt;/th&gt;&lt;th&gt;Time to live&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Operations lead&lt;/td&gt;&lt;td&gt;A workbook of twenty pasted weeks&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;/posts/capacity-planner&quot;&gt;Capacity planner&lt;/a&gt;, reading Denticon every minute&lt;/td&gt;&lt;td&gt;17 days, first commit to production&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Insurance team&lt;/td&gt;&lt;td&gt;Three exports read against each other by hand&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;/posts/reconciliation&quot;&gt;Reconciliation&lt;/a&gt;, which returns only the patients who disagree&lt;/td&gt;&lt;td&gt;8.75 hours, first commit to first live revision&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Marketing lead&lt;/td&gt;&lt;td&gt;A fresh login most days to ask Claude about marketing data&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;/posts/data-connector&quot;&gt;Data connector&lt;/a&gt; with one Google sign in&lt;/td&gt;&lt;td&gt;3 hours, first code to live&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Lab lead&lt;/td&gt;&lt;td&gt;A July mockup of a doctor portal&lt;/td&gt;&lt;td&gt;&lt;a href=&quot;/posts/doctor-portal&quot;&gt;Doctor Portal&lt;/a&gt;, live on sample data&lt;/td&gt;&lt;td&gt;3.7 hours from the sync that made it top priority&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Finance lead&lt;/td&gt;&lt;td&gt;One line in a request tab&lt;/td&gt;&lt;td&gt;Today included in Rest of month on the &lt;a href=&quot;/posts/cowork-dashboard&quot;&gt;Cowork Dashboard&lt;/a&gt;&lt;/td&gt;&lt;td&gt;19 minutes&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The finance lead’s request is the clearest picture of the loop. It arrived at 11:44 AM on October 28 through the request tab inside the Cowork Dashboard, which files into one list that six apps share through &lt;a href=&quot;/posts/launchpad&quot;&gt;Launchpad&lt;/a&gt;. The change was live at 12:03 PM.&lt;/p&gt;
&lt;p&gt;The lab lead’s portal shows the other half. At the 11:30 AM sync that same day it became the top priority, with a request for a live link starting at version 0.1. The first version went up at 12:59 PM, built from the lab lead’s own mockup, and a working revision was live at 3:12 PM. Every release note ends with the four things still pretend: sample data, files listed by name only, payments and emails off, and no doctor sign in.&lt;/p&gt;
&lt;p&gt;Underneath all of it is the &lt;a href=&quot;/posts/platform&quot;&gt;platform&lt;/a&gt;: 45 live services in 14 Google Cloud projects, listed on October 29.&lt;/p&gt;
&lt;h2&gt;How do you ship fast in a practice that holds patient data?&lt;/h2&gt;
&lt;p&gt;Speed is only safe when the code refuses the dangerous things by itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read only toward the system of record.&lt;/strong&gt; Denticon holds the charts and the bookings, and one service holds its credentials. Other apps read a mirror through the &lt;a href=&quot;/architecture/cdf-registry&quot;&gt;CDF registry&lt;/a&gt;, each with its own secret. The capacity planner’s contract with Denticon has only read methods, and a type level test fails the build if anything shaped like a write appears.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Patient details stay out of logs.&lt;/strong&gt; When an ORM upgrade started putting SQL parameters into error messages, the planner’s logger switched to describing a failed query by its Postgres code, constraint and table. A test drives a real duplicate insert with a patient style name to prove no log line carries it. The &lt;a href=&quot;/architecture/hybridge-forms&quot;&gt;forms&lt;/a&gt; logger drops 18 field names outright, from name and email to phone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Access the code enforces.&lt;/strong&gt; The &lt;a href=&quot;/architecture/data-connector&quot;&gt;data connector&lt;/a&gt; lets approved staff ask Claude about marketing data. A guard parses every query Claude writes, refuses anything but one SELECT, and points each table at that person’s own set of views. Without the patient box ticked, the contact columns are gone.&lt;/p&gt;
&lt;h2&gt;How do you measure a forward deployed engineer’s impact?&lt;/h2&gt;
&lt;p&gt;In value found, hours a team spent before, and what it costs to run. Where something is not measured, I say so.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;About $460K of patient value.&lt;/strong&gt; The first leadership dashboard joined patients to leads by name, and only 49.5 percent of Rochester patients linked. Joining on the boards’ own connect column took Rochester to 98.8 percent. The 25 patients left over were retreatment cases carrying about $460K of patient value nobody had attributed. (&lt;a href=&quot;/posts/cowork-dashboard&quot;&gt;Cowork Dashboard&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;70 hours a week.&lt;/strong&gt; That is the insurance team’s own count of the time spent reading three exports against each other by hand. The engine does that reading now and returns a short list. I have no after number yet. (&lt;a href=&quot;/posts/reconciliation&quot;&gt;Reconciliation&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;$132 of Google Cloud&lt;/strong&gt; from October 1 to 28 for the six projects the billing export measures. &lt;a href=&quot;/posts/what-it-costs&quot;&gt;What it costs&lt;/a&gt; has the table and the projects not measured yet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I have not measured hours saved on the planner either. Nobody timed the workbook, so there is no before number.&lt;/p&gt;
&lt;h2&gt;What makes a good forward deployed engineer?&lt;/h2&gt;
&lt;p&gt;Five habits, each one from the work above:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Write the old rules down before replacing them.&lt;/strong&gt; The planner reproduces all 360 day cells of the old workbook, checked on every test run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ask with a default.&lt;/strong&gt; Questions go out with an answer already coded, so the build never waits on a reply.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ship something real early.&lt;/strong&gt; A live link on the day the work becomes the priority, and live data by day two.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Put the guards in code.&lt;/strong&gt; Read only by contract, names out of logs, access checked on every query.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Say no with a reason.&lt;/strong&gt; The planner ranks cover suggestions on five plain rules our operations lead can read, with no generated advice. Reconciliation keeps a model out of the verdict until each practice confirms its terminology list.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;How to read a forward deployed engineer’s portfolio&lt;/h2&gt;
&lt;p&gt;Look for systems in production with real users, the time from an ask to live, what each system does when it breaks, and what it costs a month.&lt;/p&gt;
&lt;p&gt;Every system I run has a drawing at &lt;a href=&quot;/architecture&quot;&gt;/architecture&lt;/a&gt;. Each one marks which parts I built, which Google manages and which belong to outside systems. Click a part to read what it does and how it fails. Below the drawing sit three lists, When it breaks, What I tuned and Guards, each item with the source file it came from, then the October cost where the billing export measured it. Start with the &lt;a href=&quot;/architecture/platform&quot;&gt;platform map&lt;/a&gt;, then open the &lt;a href=&quot;/architecture/capacity-planner&quot;&gt;capacity planner&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Lead forms off Tally and Zapier, into our own Google Cloud</title><link>https://sampreethavvari.com/posts/hybridge-forms/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/hybridge-forms/</guid><description>Every patient inquiry on our websites went through Tally, then Zapier, before it reached Monday. I built the forms service that replaces both, around one rule: no inquiry is ever lost.</description><pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Every form on our four websites ran on Tally. Tally handed each answer to Zapier, and Zapier put it on our Monday lead board. So every patient inquiry passed through two outside companies before anyone at the practice saw it.&lt;/p&gt;
&lt;p&gt;Our VP of finance asked to move it inside our own Google Cloud, with one rule: no submission is ever lost. Our digital experience director added the rest. Patient privacy, good on a phone, pushes to Monday, conditional questions, spam protection, and the power to edit and add forms without waiting on me.&lt;/p&gt;
&lt;p&gt;Before writing code I fetched all 539 pages in the four sitemaps and read every Tally embed. There were 32 live forms.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A forms service in our own Google Cloud project. A public app serves each form in an iframe on the practice websites and saves what patients send. A private worker puts each lead on Monday and emails the copies. Staff build forms and read inquiries in a dashboard behind Google sign in, and a job tests everything at &lt;span&gt;2 AM&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;The public app holds no dashboard code at all, so a bug there has no admin page to reach. &lt;a href=&quot;/architecture/hybridge-forms&quot;&gt;Open the drawing&lt;/a&gt; to see every part.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first commit went in on September 30 at 11:58 AM: the design, the plan and the 32 Tally forms as fetched. By 4:36 PM the importer read all 32. That night I worked until 5:36 AM. On October 6 a change went in at 3:55 AM, and the first deploy ran that evening.&lt;/p&gt;
&lt;p&gt;On October 14 Monday delivery, email copies and the Hybridge look all went live. That afternoon our digital experience director swapped Elmwood’s contact page from Tally to ours. On October 15, in 79 minutes, a step by step quiz, Delete, an email back to the sender, and wildcard hosts for the Webflow designer went live.&lt;/p&gt;
&lt;p&gt;On October 26 the digital team saw only UTM Source reaching Monday. The forms had saved all six tracking fields, but only one had a column mapped. The fix merged that afternoon as v2.6.1, and a script filled the missing columns on the 67 leads already on the board. Then a marketing colleague asked for a Skip button on the quiz. It went live on October 28 as v2.7.0.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Nothing lost, even with the database down.&lt;/strong&gt; The page makes a random id before it sends, and the server saves with a create only write, so a double click never makes two leads. If Firestore does not answer in time, the record goes to a private bucket, the patient still sees “sent”, and the nightly job moves it in. If both fail, the browser retries for about 15 seconds with the same id, then keeps the answers on the device with a Try again button.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fast, and only once.&lt;/strong&gt; At first a lead waited for the minute sweep. Now the public app adds a Cloud Tasks wake right after the save, giving the queue 1.5 seconds before answering the patient anyway. The sweep stays as the backup. Overlapping sweeps claim each delivery in its own transaction, and before any retry the worker looks for a lead with the same email made since the first try, so a crash after Monday made the item never makes a second one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where the answers can go.&lt;/strong&gt; I listed every place a patient’s answers could reach, then closed the rest. The form is an iframe from our own address, so ad pixels on the websites cannot read it. reCAPTCHA gets only a token. Firestore rules deny every browser, and the address is kept only as a salted hash. Cloud Run’s request logs for both apps are excluded, since they would hold query strings and addresses. And the logger is a filter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// packages/core/src/log.ts&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; key&lt;/span&gt;&lt;span&gt; of&lt;/span&gt;&lt;span&gt; Object.&lt;/span&gt;&lt;span&gt;keys&lt;/span&gt;&lt;span&gt;(fields)) {&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;DROPPED_FIELD_NAMES&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;has&lt;/span&gt;&lt;span&gt;(key.&lt;/span&gt;&lt;span&gt;toLowerCase&lt;/span&gt;&lt;span&gt;())) &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;  const&lt;/span&gt;&lt;span&gt; value&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; sanitizeValue&lt;/span&gt;&lt;span&gt;(fields[key]);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  if&lt;/span&gt;&lt;span&gt; (value &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; undefined&lt;/span&gt;&lt;span&gt;) &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;  record[key] &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; value;&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;It drops &lt;span&gt;18 field names&lt;/span&gt; outright, and any value that is not plain text, a number or a boolean, so a nested object cannot smuggle an email through. Only two places outside the project get an inquiry: the Monday board and each form’s email copies. The public app can reach neither. Only the private worker holds the Monday token and the right to send mail.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Patient file uploads.&lt;/strong&gt; There is no upload question and no upload endpoint. A send is JSON only, 32 KB at most. The only files in the system are ones staff attach to the email back to a patient, in a private bucket with no public or signed links.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A theme per website.&lt;/strong&gt; Tally matched each site’s colours. Every form now wears one Hybridge look, and I removed the theme editor. One look to test on every phone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A new setting for Skip.&lt;/strong&gt; The quiz request could have become another checkbox. Instead Skip shows on any step whose visible questions are all optional, so the director just turns Required off. A skipped step sends nothing: Monday leaves the column empty and the PDF leaves the row out.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Elmwood’s contact form has run on it since October 14. The Email Us and consultation forms on the Hybridge Implants site feed the same Monday board, with no Zapier in between. The director builds and publishes forms, one site at a time. The nightly job has run 29 times.&lt;/p&gt;
&lt;p&gt;It cost $9 in October, measured from the billing export, a partial month since the first cost landed on October 6. One warm public instance is $7.14 of it.&lt;/p&gt;
&lt;p&gt;Not measured: how many inquiries came through, or how much sooner a patient gets a call. Still open: how long real inquiries are kept, and whether new sign ins should wait for approval. Today every Hybridge account starts as a viewer who can read inquiries, as asked. It is one switch to change.&lt;/p&gt;
&lt;p&gt;What I would keep: save first, deliver from the saved copy, and treat the logger as a filter.&lt;/p&gt;</content:encoded></item><item><title>Two Hybridge websites rebuilt in a day, both previewing on Cloud Run</title><link>https://sampreethavvari.com/posts/hybridge-websites/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/hybridge-websites/</guid><description>The patient site and the lab site, rebuilt in the premium lab look by parallel page agents with a marketing review, checked at 1440 and 390 and previewing on Cloud Run.</description><pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Hybridge has two public websites. The patient site is where someone weighing full arch implants, at an average of $18K to $22K an arch, first meets the practice. The lab site is for the dentists who send our lab their cases. In our design doc’s words, Hybridge is “a mobile first company.”&lt;/p&gt;
&lt;p&gt;The ask on October 28 was to recreate the patient site “with better modern taste,” more colour and better UX, then the lab site. My first lab landing put the hero on a dark navy stage. The team’s verdict came back that night: “not a big fan of the dark blue.” Looking at it again, I found it wordy too.&lt;/p&gt;
&lt;p&gt;What stayed was the next version of the lab home: porcelain whites, titanium greys, one cyan scan light used like a laser line, studio renders and very few words. The design doc calls it engineered to the micron, and both sites follow it now.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Two Astro sites on the Hybridge design system, each its own Cloud Run service in the website’s own Google Cloud project. Every page is prerendered at build time. Both open with the H loader, where the Hybridge H builds itself (posts rise, the bridge sets, a smile line draws), and play a frosted glass veil between pages.&lt;/p&gt;
&lt;p&gt;The patient site has a 3D full arch to turn on the home page, twelve patient stories that play in place from our own copies, and a referral form that saves a PDF on the dentist’s device. The lab site opens on an exploded full arch that assembles as you scroll while a cyan scan line turns the CAD wireframe into the real render.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/architecture/hybridge-website&quot;&gt;Open the drawing&lt;/a&gt; for the patient site, or &lt;a href=&quot;/architecture/hybridge-lab&quot;&gt;the lab site’s drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;Times are New York time, from git and the Cloud Run revision list.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Oct 28, 5:28 PM.&lt;/strong&gt; First commit on the patient site: the whole site and its docs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;5:48 PM.&lt;/strong&gt; First revision on Cloud Run, in the website’s own project.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;6:37 PM.&lt;/strong&gt; A landing page for every tab.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;9:45 PM.&lt;/strong&gt; The lab site’s foundation, with a crawl of the live lab site: 73 pages and 118 images as the source of facts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Oct 29, 1:31 AM.&lt;/strong&gt; The lab site rebuilt in the new style. Live at 1:34 AM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;4:27 AM.&lt;/strong&gt; Touch parity on the lab site, so every hover interaction works by tap.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;5:02 AM.&lt;/strong&gt; The patient site’s premium round, every page rebuilt to the lab’s standard.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Afternoon.&lt;/strong&gt; I noticed the lab hero’s arch was an &lt;span&gt;upper arch stacked like a lower one&lt;/span&gt;. The flip was committed at 2:30 PM and live at 2:32 PM.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both repos deploy by hand from a clean, pushed main branch.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Many builders, one rulebook.&lt;/strong&gt; Both sites were built by parallel agent sessions, one per area (eight areas on the patient site, seven on the lab site), with one session owning the shared styles, layout and scripts. Each repo has a rules file every builder reads first. Then an agent played head of marketing: it measured each page at 1440 and 390, wrote the cuts and checked again after round two. The patient home page went from 13.7 screens of scroll at 1440 to 7.0.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phone fixes leave desktop alone.&lt;/strong&gt; Phone fixes live only inside a max width of 759 pixels. Each changed page is captured at 1440 before and after a phone fix and compared pixel by pixel, and the diff has to print &lt;span&gt;IDENTICAL&lt;/span&gt;. Touch parity works the same way: every hover style sits behind a fine pointer media query, and whatever hover shows also opens with one tap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A 3D arch that loads only near view.&lt;/strong&gt; The patient home page ships a still photo of the arch and a tiny script. three.js loads when the card comes within 600 pixels of view and the browser can do WebGL, and frames render only while something moves.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// src/components/home/ArchViewer.astro&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;webgl&lt;/span&gt;&lt;span&gt;() &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; (needsGzip &lt;/span&gt;&lt;span&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span&gt; typeof&lt;/span&gt;&lt;span&gt; DecompressionStream &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; &apos;undefined&apos;&lt;/span&gt;&lt;span&gt;)) { el.dataset.state &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &apos;still&apos;&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;el.dataset.state &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &apos;loading&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;./arch-viewer.js&apos;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  .&lt;/span&gt;&lt;span&gt;then&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;m&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; m.&lt;/span&gt;&lt;span&gt;mount&lt;/span&gt;&lt;span&gt;(el))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  .&lt;/span&gt;&lt;span&gt;catch&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;err&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; { console.&lt;/span&gt;&lt;span&gt;warn&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;3D arch unavailable&apos;&lt;/span&gt;&lt;span&gt;, err); el.dataset.state &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &apos;still&apos;&lt;/span&gt;&lt;span&gt;; });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}, { rootMargin: &lt;/span&gt;&lt;span&gt;&apos;600px 0px&apos;&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Dark navy blocks.&lt;/strong&gt; After the dark blue verdict, neither site has a large dark block. On the lab site, night survives as a small accent on the Submit a case pill. On the patient site, the pages for referring dentists went light as well.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generated people.&lt;/strong&gt; Nano banana renders are for products, materials and the arch, each keyed to real transparency. Patients, doctors, the offices and the lab stay real photos.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Working around a locked video host.&lt;/strong&gt; The patient stories sit on a video host locked to Hybridge’s domain and account. I left the lock alone and used Hybridge’s own published copies from its YouTube channel: twelve stories at 720p, self hosted, playing in place.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Both sites are previews on Cloud Run and are not on their public domains yet. Every page carries noindex, and the forms show what they would send and send nothing. They exist for the team to review. I have no visitor numbers.&lt;/p&gt;
&lt;p&gt;Cost is not measured. The billing export for the website’s project only switched on October 28, 2026, so there is no measured month yet. Both services scale to zero.&lt;/p&gt;
&lt;p&gt;Before either replaces a live site: wire the forms to real intake, move the lab’s legal texts over, redirect the remaining live addresses (the lab site already redirects 21), then lift noindex and point the domains.&lt;/p&gt;
&lt;p&gt;What I would keep: write the rules file before the first page, and read each page at 1440 and 390 before anyone else does.&lt;/p&gt;</content:encoded></item><item><title>The open source app that ran my job hunt 1,008 times</title><link>https://sampreethavvari.com/posts/jobpilot-v2/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/jobpilot-v2/</guid><description>JobPilot watched 163 company boards, scored what survived the filters with Gemini, rewrote each resume against a strict judge, and drafted every email for me to send. Here is the whole system.</description><pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;/posts/jobpilot&quot;&gt;The first version&lt;/a&gt; took one night. It found jobs and wrote resumes. Then I used it every morning, and the gaps showed up one at a time.&lt;/p&gt;
&lt;p&gt;My resume checker gave my real resume &lt;span&gt;100 out of 100&lt;/span&gt;. ResumeWorded gave the same file 73. Recruiter replies sat in my inbox while the Sheet still said Applied. Adding a company meant editing a config file.&lt;/p&gt;
&lt;p&gt;I was the only user, so the spec was whatever had annoyed me the morning before.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;JobPilot grew into a small product. A Cloud Run job polls 163 company boards and four aggregators. A Google Sheet is both the database and the dashboard. A resume judge drives a rewrite loop, an inbox watcher reads replies, outreach becomes Gmail drafts, and a console with a copilot sits on top. It is MIT licensed on &lt;a href=&quot;https://github.com/SampreethAvvari/job-pilot&quot;&gt;GitHub&lt;/a&gt;, with a fork guide written to be handed to a coding agent.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/architecture/jobpilot&quot;&gt;Open the drawing&lt;/a&gt; to see every part. The &lt;a href=&quot;/demos/jobpilot&quot;&gt;live demo&lt;/a&gt; runs the real console on fictional data.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;v1.0 went public at 7:12 PM on June 10. The inbox watcher went in that same night and was done at 9:56 PM. By 11:10 the next morning it had a guard for one time passcode emails, after it alerted me twice that a company had replied when the company had only sent a login code.&lt;/p&gt;
&lt;p&gt;June 11 brought the company watchlist and four more ATS types: Workday, SmartRecruiters, Workable and Recruitee. At 12:19 AM on June 12 the job got 2 GiB of memory, because the parallel fetch across 163 boards ran out at the default. The copilot shipped that afternoon and the US only location wall at 7:55 PM. Cold outreach landed at &lt;span&gt;4:41 AM on June 18&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;The busiest day came a month later. On July 23 an audit of the Sheet turned into a rebuilt console, and July 24 went into knowledge graphs of my portfolio and GitHub to ground the copilot. The last change went in at 3:23 AM on July 25.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A judge I could check against a real one.&lt;/strong&gt; The first checker only measured keyword coverage, which is why it loved everything. I rebuilt it as a replica of ResumeWorded’s rubric: about 40 checks, weighted impact 35, brevity 20, style 15, sections 15 and soft skills 15. Calibrated against the real thing, it scored my old resumes 76 to 86 where ResumeWorded said 73. Then every tailored resume goes through a loop:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# src/jobpilot/rewrite_loop.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;report &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; judge(tex, pdf, keywords)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; best &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; None&lt;/span&gt;&lt;span&gt; or&lt;/span&gt;&lt;span&gt; report[&lt;/span&gt;&lt;span&gt;&quot;score&quot;&lt;/span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;span&gt; best[&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;    best &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (report[&lt;/span&gt;&lt;span&gt;&quot;score&quot;&lt;/span&gt;&lt;span&gt;], tex, pdf, report)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;issues &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; report[&lt;/span&gt;&lt;span&gt;&quot;issues&quot;&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; passes(report, min_score):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    break&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The issues go back to Gemini, which may reorder and rephrase but never invent. Up to 10 tries, an early exit at 90 with 85 percent keyword coverage, and the best attempt wins. My real ResumeWorded score went from 73 to 82 after one pass.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The same job, five times.&lt;/strong&gt; One role sat in the Jobs tab five times, and there were 23 duplicate groups in 426 rows. Exact duplicates were zero. The key was the problem: it hashed company, title and location, and boards list one role per city. The key is now company and title, and the Sheet’s old ids are recomputed from those two columns, so rows saved under the old key are recognized.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Failures that made no sound.&lt;/strong&gt; The fast fetch never ran once. Its trigger passes arguments to the job, which needs a permission the plain invoker role lacks, so every attempt returned 403 to nobody. The console’s Tailor and Draft buttons failed the same way. One narrow role on the job fixed both, and the bug log tells every fork to check job status after any IAM change.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Guessing email addresses.&lt;/strong&gt; Outreach uses a careers email the company published, found on its site, in the job post, or through a web search. No recipient means no draft.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A database migration.&lt;/strong&gt; The July audit found 5,093 rows in the Jobs tab and 2,960 New rows older than 30 days. I kept the Sheet and added an Archive tab, a 14 day window and a fit 75 gate. Dedup reads both tabs, so nothing archived comes back.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Big tech portals.&lt;/strong&gt; The seeded watchlist leaves out the largest companies with custom career sites built to block scrapers. The aggregators already cover them.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It ran my search every morning. The pipeline ran 1,008 times from June 10 to October 8: every 30 minutes through June 25, then twice each morning, at 7 and 9. By the July audit it was adding 22 to 58 fresh rows a day.&lt;/p&gt;
&lt;p&gt;In September, its last full month, Google billed $11.64 for the project, and $7.86 of that was Gemini. My own copy has been off since October 8. The code stays public, and the demo runs the console on fictional data.&lt;/p&gt;
&lt;p&gt;I never measured hours saved or which interviews came from it.&lt;/p&gt;
&lt;p&gt;What I would keep: run the cheapest filter first, check any judge against a real grader before trusting it, and keep a person on the send button.&lt;/p&gt;</content:encoded></item><item><title>I turned my job hunt into a pipeline in one night</title><link>https://sampreethavvari.com/posts/jobpilot/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/jobpilot/</guid><description>Reading reposted LinkedIn listings to reach a no sponsorship line in paragraph six was not a plan. So one night I built JobPilot: fresh jobs from the source, scored and tailored while I slept.</description><pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;I needed a job that would sponsor a visa, and LinkedIn made me find out the slow way. A posting says two weeks ago, it has been reposted twice, and paragraph six says the company cannot sponsor. I read all of it anyway, because that line could sit anywhere.&lt;/p&gt;
&lt;p&gt;Recruiters run their side of hiring on an ATS with dashboards and automation. I was running mine on a browser tab and a spreadsheet I forgot to update. A job search is a pipeline, and I was doing every stage by hand.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;JobPilot reads companies’ own job boards, drops the postings I could never take, scores the rest against my profile with Gemini, and writes a tailored one page resume and cover letter for the good ones. A Google Sheet is the database. A small console behind Google sign in sits on top, and a digest email lists the best matches.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/architecture/jobpilot&quot;&gt;Open the drawing&lt;/a&gt; to see every part. This post is the first version. The &lt;a href=&quot;/posts/jobpilot-v2&quot;&gt;full tour&lt;/a&gt; covers what it grew into.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first version took one evening, &lt;span&gt;about five hours by my count&lt;/span&gt;. Google Cloud only saw the end. The project was created at 3:14 AM on June 10. The pipeline job existed three minutes later and ran for the first time at 3:25. The console went up at 3:54.&lt;/p&gt;
&lt;p&gt;At 7:12 PM the same day the code went public on GitHub under MIT as JobPilot v1.0, with seven job sources and four resume variants. By midnight the pipeline had run 11 times. Two days later it was running every 30 minutes.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Resumes a parser could not read.&lt;/strong&gt; My first PDFs came out of XeTeX. They looked perfect, and a text extractor read them as “New Y ork” and “T erraform”, which is what an ATS would see. Switching to pdflatex with the cmap package fixed the text layer. A check script now pulls the text out of the real PDF and fails any resume over one page or under 85 percent keyword coverage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A Tailor button that ran everything.&lt;/strong&gt; The console’s Tailor button was meant to start the job for one row. It started the whole pipeline. Cloud Run’s per run arguments replace a Dockerfile’s CMD, so mine were thrown away. Arguments added to an ENTRYPOINT append, so one line fixed it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# Dockerfile&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;ENTRYPOINT&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;python&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;-m&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;jobpilot&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The cheapest filter goes first.&lt;/strong&gt; Sixteen regex patterns drop citizenship, clearance and no sponsorship postings before any model sees them, and title words drop senior, staff, contract and intern roles. Whatever survives goes to Gemini in batches of 10. If the text still hints that sponsorship is unlikely, the scorer caps the fit at 10 and the row is &lt;span&gt;rejected with a note&lt;/span&gt;.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Sending anything on its own.&lt;/strong&gt; Outreach lands in Gmail drafts, and I press send. Reply tracking can move an application’s status forward and never overwrites an edit I made by hand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A real database.&lt;/strong&gt; The Sheet is free, I can filter it, and I can fix a row from my phone. For one person’s job hunt, a database server would be one more thing to run.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LinkedIn as the main source.&lt;/strong&gt; It stays in, through an Apify actor capped at 50 results a run so it lives on free credits. The company boards carry the load. Greenhouse, Lever and Ashby publish their job boards through public APIs that need no key.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;I used it from the first morning. In late June it settled into two runs each morning, and it ran 1,008 times before my copy stopped on October 8. The code stays public, and a demo of the console runs on fictional data.&lt;/p&gt;
&lt;p&gt;I never measured hours saved or which interviews came from it, so I will not guess.&lt;/p&gt;
&lt;p&gt;What I would keep: run the cheapest filter first, and let the machine write the drafts while I decide what gets sent.&lt;/p&gt;</content:encoded></item><item><title>Every Hybridge app on one board, every request in one list</title><link>https://sampreethavvari.com/posts/launchpad/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/launchpad/</guid><description>Staff needed one place to open every app, leaders wanted to see what was being built, and change requests got lost in chats. I built one board and one request list six apps share.</description><pubDate>Fri, 23 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;By October the list of Hybridge projects had reached 38: 10 apps people open, 12 services behind them, and the rest in development, upcoming or stalled. Staff needed one place to open any of them.&lt;/p&gt;
&lt;p&gt;Four leaders wanted to see what is being built now, what is next and what is stalled. A board I built in June did part of this, with milestone bars and a commit calendar.&lt;/p&gt;
&lt;p&gt;Then the asks. People who use the apps want changes, and my design note from October 22 says those asks “arrive in chats and in passing, with no screenshot, no ID and no record of what happened to them.”&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Launchpad is one page behind Google sign in. The Apps tab shows every project as a card, in panes by status, and anyone at Hybridge opens an app from there. The four leaders also get Work (now, next, stalled and shipped, with a 1 to 5 complexity meter) and History. A welcome tour walks first visitors through it.&lt;/p&gt;
&lt;p&gt;I am the only editor. I drag a card to another pane, hide it, reword it or add a project, and each save writes the project and a history entry with who, when, and every field’s old and new value.&lt;/p&gt;
&lt;p&gt;Then a Feature requests tab: paste a screenshot with Ctrl+V, write a line, send. Each request gets an FR number that never changes. The same screen lives inside six other apps, which all send to one relay. &lt;a href=&quot;/architecture/launchpad&quot;&gt;Open the drawing&lt;/a&gt; for the board, and &lt;a href=&quot;/architecture/feature-requests&quot;&gt;the relay’s drawing&lt;/a&gt; for the apps that call it.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The June board was built over June 16 and 17. Its last commit, at 1:41 AM, fixed its calendar’s month labels &lt;span&gt;once and for all&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;Launchpad started at 4:40 PM on October 22. The first revision went live at 5:48 PM and failed every page: my laptop used a JSON store, and the production bundle hid the Firestore class under &lt;code&gt;default&lt;/code&gt;. The fix was live at 5:58 PM, 78 minutes after the first commit. That evening the June board went dark and its daily sync paused.&lt;/p&gt;
&lt;p&gt;The request list came together on the afternoon of October 23, starting at 1:19 PM:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;2:12 PM: the request tab merged, with storage, the API and Copy.&lt;/li&gt;
&lt;li&gt;2:48 PM: the screen as one element, and the relay for other apps.&lt;/li&gt;
&lt;li&gt;3:16 PM: the tab merged into five apps.&lt;/li&gt;
&lt;li&gt;3:24 PM: the relay live. &lt;span&gt;FR-0001&lt;/span&gt; arrived eight minutes later.&lt;/li&gt;
&lt;li&gt;4:19 PM: any app can delete a request.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Chat pings went live on October 27. The Doctor Portal became the sixth caller on October 29.&lt;/p&gt;
&lt;p&gt;At 11:44 AM on October 28, our VP of finance filed FR-0004 from the Cowork Dashboard: Rest of month on the cash flow view should include today. The fix merged at 11:58 AM, and the new revision was live at 12:03 PM, 19 minutes after the request.&lt;/p&gt;
&lt;p&gt;I deploy by hand from main.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Apps cannot get through IAP.&lt;/strong&gt; In June, IAP on Cloud Run refused a scripted service account token from the old board’s sync job. So the relay is Launchpad’s image deployed a second time as &lt;code&gt;launchpad-requests&lt;/code&gt;, with no IAP. Cloud Run lets in only the six app accounts, and the code checks again before it trusts a name:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// lib/requests/relay.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; caller&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; verifyRelayToken&lt;/span&gt;&lt;span&gt;(bearer, { audience: env.&lt;/span&gt;&lt;span&gt;RELAY_AUDIENCE&lt;/span&gt;&lt;span&gt;, keys });&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;caller) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; null&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; app&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; parseRelays&lt;/span&gt;&lt;span&gt;(env.&lt;/span&gt;&lt;span&gt;REQUEST_RELAYS&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(caller);&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;app &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; h.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;x-relay-app&quot;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;!==&lt;/span&gt;&lt;span&gt; app) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; null&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; email&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; decodeHeader&lt;/span&gt;&lt;span&gt;(h.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;x-relay-user-email&quot;&lt;/span&gt;&lt;span&gt;))?.&lt;/span&gt;&lt;span&gt;trim&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;toLowerCase&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;email &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; !&lt;/span&gt;&lt;span&gt;STAFF_EMAIL&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;test&lt;/span&gt;&lt;span&gt;(email)) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; null&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The security review before merge added one rule: nobody is an admin through an app. Statuses, headings and Copy stay in Launchpad.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One screen inside six apps.&lt;/strong&gt; Five apps are Next.js and one is Express, so the screen is one custom element in a shadow root that each app copies in. Inside a shadow root the active element is the host, so Tab walked out of dialogs until the focus trap learned to look inside. The editor’s stylesheet never reached the shadow root either, and a typed trailing space vanished until its base CSS moved into the element.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;25 MB through three hops.&lt;/strong&gt; A screenshot goes from the browser to the app, the relay and a private bucket. Next cuts a body copied into middleware at 10 MB, so a 12 MB upload failed in testing. The request API skips middleware now, each app streams the body through, and the browser sends big files two at a time. Files come back with a sandbox policy.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Other editors.&lt;/strong&gt; The June board let managers edit, reorder and remove projects. Launchpad has one editor, and the four leaders read the board and its full history.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Coding sessions that start on their own.&lt;/strong&gt; A scheduled cloud session cannot take a follow up into the same session, and requests get follow ups. Copy puts the heading, every message and each attachment’s download line in one block, and I paste it into Claude Code myself. Copy never changes a status.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Public files and patient facing apps.&lt;/strong&gt; The bucket is private, with no public or signed links, and I read attachments with my own login. The tab stays out of the patient surveys and public forms.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;The board holds 38 projects, 22 of them live, and its history holds 21 edits. I do not track page views, so I have no visit numbers.&lt;/p&gt;
&lt;p&gt;The request counter reached FR-0006 on October 29. Four requests about three apps are on the list: two shipped, one in progress and one queued.&lt;/p&gt;
&lt;p&gt;The whole Google Cloud project cost $0.32 from October 1 to 28, most of it the retired June board’s secrets and sync job. Request time for Launchpad, the relay and the old board together came to under two cents.&lt;/p&gt;
&lt;p&gt;Still open: Doc Coach and NPC Coach do not have the tab yet.&lt;/p&gt;
&lt;p&gt;What I would keep: one image deployed twice, so the board and the relay always run the same code.&lt;/p&gt;</content:encoded></item><item><title>I taught Llama 3 to argue with a judge trained on Reddit deltas</title><link>https://sampreethavvari.com/posts/llama-rlhf/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/llama-rlhf/</guid><description>Research at NYU. Replies that earned a ChangeMyView delta became training pairs, a reward model learned to judge them, and GRPO let a fine tuned Llama 3 8B practice against it.</description><pubDate>Wed, 10 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;I worked on this in Prof. Marco Morucci’s group at NYU. The question sounds simple: take a controversial claim and train a model to write a reply that people find more convincing than what it already says.&lt;/p&gt;
&lt;p&gt;Simple to ask. Sneaky to pull off. You need examples of arguments that actually changed someone’s mind, and Reddit has a community built for that. On ChangeMyView a person posts an opinion and awards a delta to the reply that moved them. That delta is a real human saying “okay, you convinced me.”&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A training pipeline on NYU’s GPU cluster, every stage starting from the same Llama 3 8B loaded in 4 bit. Pairs from the same debate, one reply that earned a delta and one that did not, feed two jobs. Supervised fine tuning teaches the writer from the winners. A reward model learns to score the winner above the loser.&lt;/p&gt;
&lt;p&gt;Then GRPO lets the writer practice. For each premise it writes four replies, the reward model scores each one, and the replies that beat their group’s average get pushed up. An eval script scores held out premises with BLEU, ROUGE-L and the reward model. &lt;a href=&quot;/architecture/llama-rlhf&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;Research runs in bursts between classes, and the git history shows it, running from August 6, 2024 to June 16, 2025. It starts with a Longformer judge. The next push landed at &lt;span&gt;1:12 AM&lt;/span&gt; on August 15 and moved the judge to Llama 2 7B. GRPO arrived in February 2025, a rewrite on Unsloth in May, and the final SFT, reward and GRPO scripts on June 16.&lt;/p&gt;
&lt;p&gt;Every run name carries its date, from June09_SFT_Run1 to June15_GRPO_Run1. The habit came from two experiments that grabbed the same name. One overwrote the other halfway through, and I recovered from checkpoints.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The judge decides everything.&lt;/strong&gt; It went through three versions. A Longformer classifier on 5,000 replies, 2,511 with a delta and 2,489 without. Then a Llama 2 7B judge. Then a Llama 3 8B reward model with a one number head, trained with TRL. An early judge quietly decided that longer meant more convincing, and a writer trained against that judge learns to &lt;span&gt;ramble&lt;/span&gt;. The fix was in the pairs, built so length could not give the winner away.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Score only the reply.&lt;/strong&gt; In fine tuning the prompt tokens get the ignore label, so the loss counts the winning reply alone and the model never learns to echo the premise back:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# Sampreeth/Unsloth/SFT_unsloth.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;labels &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;] &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; len&lt;/span&gt;&lt;span&gt;(base_tokens[&lt;/span&gt;&lt;span&gt;&quot;input_ids&quot;&lt;/span&gt;&lt;span&gt;])&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;labels &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; full_tokens[&lt;/span&gt;&lt;span&gt;&quot;input_ids&quot;&lt;/span&gt;&lt;span&gt;][&lt;/span&gt;&lt;span&gt;len&lt;/span&gt;&lt;span&gt;(labels):]  &lt;/span&gt;&lt;span&gt;# only the chosen reply is scored&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;An 8B model on a shared cluster.&lt;/strong&gt; NF4 with double quantization holds the base, and rank stabilized LoRA at rank 64 on every attention and MLP projection does the learning. A premise is cut to its first 470 words so prompt and reply fit in 768 tokens. GRPO samples four replies of up to 256 tokens from a 512 token prompt.&lt;/p&gt;
&lt;h2&gt;What I learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Lock the starting point.&lt;/strong&gt; A couple of pieces were once built on slightly different base models, and the scores drifted in ways nobody could explain. Now SFT, the reward model and GRPO all load the same 4 bit Llama 3 8B.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Look for how the judge could cheat.&lt;/strong&gt; I caught the length trap by asking that question before a long run, and it was cheaper than any amount of compute after.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spend the time on the data.&lt;/strong&gt; I also tried ORPO in April 2025, which folds preference into one stage. Changing the pairs taught me more than changing the loop.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It is research code in the lab’s private repo, and nothing serves traffic. The eval script compares the GRPO adapter against the untrained base on 100 held out premises. I have not put a win rate on this page. The scores from those runs live in Weights and Biases, and I only quote what I can point to in the repo.&lt;/p&gt;
&lt;p&gt;What I would keep: plan around the obvious way the judge can be fooled before you let a model practice against it.&lt;/p&gt;</content:encoded></item><item><title>Loan Radar: training and serving that load one saved transform</title><link>https://sampreethavvari.com/posts/loan-radar-mlops/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/loan-radar-mlops/</guid><description>A three person NYU class project that scores loan default risk from the public LendingClub book. I built the data pipeline and the tests, and made training and serving share one transform.</description><pubDate>Thu, 01 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our Machine Learning Systems course at NYU asked for a system, not a notebook. The brief we wrote named a user: credit risk officers at regional banks and micro finance lenders, whose rule based underwriting was slow and subjective. They wanted a low or high default risk label back while the applicant waited.&lt;/p&gt;
&lt;p&gt;The data was the public LendingClub loan book, 2007 to 2018, about 2 GB of accepted loans. A model like this rarely fails on the math. It fails when the loan scored at serving time is prepared differently from the loans it learned from.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Loan Radar was a three person project on Chameleon Cloud. My part was data engineering and offline evaluation: the ETL containers, the compose file that runs MLflow and a Ray cluster, and the pytest suite. Teammates ran training and built the FastAPI scorer, the Flask page and the monitoring.&lt;/p&gt;
&lt;p&gt;The ETL pulls the loan book, turns each loan’s status into a risk label, fits medians, modes, encoders and a scaler on the training rows only, and saves that fitted state as &lt;span&gt;one pickle&lt;/span&gt; beside the data. Validation and the API both load it, so a loan gets the same treatment wherever it goes. &lt;a href=&quot;/architecture/loan-radar-mlops&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The repo opened on March 30, 2025, with our proposal. The build ran from May 5 to May 11. My first commit on May 10 was at 1:39 AM. The change that saves the fitted transform went in at 11:10 PM that night. At 4:01 AM on May 11 the three risk levels became two, low and high, and the stress test sets followed from 6:31 AM. My last README commit landed at 11:34 PM.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;One transform, two places.&lt;/strong&gt; The first version fit its medians and its scaler on whatever file it was handed. That is fine for training and wrong everywhere else, because a validation row filled with validation medians has already seen its own answers. So I split it: the training step fits and saves, and every later step loads.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# etl/docker/docker-compose-etl.yaml, val-transform-data&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;artifacts &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; joblib.load(&lt;/span&gt;&lt;span&gt;&quot;/mnt/data/LoanData/train/transform_artifacts.pkl&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;median_values &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; artifacts[&lt;/span&gt;&lt;span&gt;&quot;median_values&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;scaler &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; artifacts[&lt;/span&gt;&lt;span&gt;&quot;scaler&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;df[numeric_cols] &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; scaler.transform(df[numeric_cols])&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Tests that know the answer.&lt;/strong&gt; A validation score says little about how a model fails. So I cut a stratified tenth off validation, 22,607 loans, and built five altered copies: the top five features zeroed, everything else zeroed, the top five raised by &lt;span&gt;ten million&lt;/span&gt;, everything else raised, and Gaussian noise on every feature. The top five by importance were sub grade, revolving accounts opened in the last year, interest rate, unverified income and funded amount. pytest checks macro F1 and average confidence on each copy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A skewed label.&lt;/strong&gt; About one loan in seven was high risk: 244,480 of 1,808,534 training rows. XGBoost’s scale_pos_weight of 6.4 is that ratio, so the two classes carry about equal weight in training.&lt;/p&gt;
&lt;h2&gt;What I learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Set a threshold from a baseline.&lt;/strong&gt; The noise checks failed. They asserted a macro F1 below 0.10, and the model scored 0.373 and 0.517. A model that always answers low risk already scores a macro F1 near 0.46 on that mix, so a working model could never pass. I had picked the ceiling by feel. Now I score a dumb baseline first and set the bar from it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Know which number you measured.&lt;/strong&gt; The 0.79 ms median, 0.87 ms at the 95th percentile, is the model scoring one row inside a notebook over 100 calls. A request through FastAPI adds parsing and the transform on top, and we never load tested that path. Next time I measure the round trip first.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It ran for the class demo on Chameleon and is not running now. No lender used it. In 2 fold cross validation the served model reached a ROC AUC of 0.71 and caught about two thirds of the high risk loans.&lt;/p&gt;
&lt;p&gt;What I would keep: fit the data transform once, save it, and load that same file everywhere a row gets scored. And write tests whose right answer you already know.&lt;/p&gt;
&lt;p&gt;Code and README: &lt;a href=&quot;https://github.com/ds28-ops/loan-default-risk-assessment&quot;&gt;Loan Radar on GitHub&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>AI has the memory of a goldfish with a film degree</title><link>https://sampreethavvari.com/posts/magic-hour/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/magic-hour/</guid><description>At a GDG Brooklyn hackathon at NYU Tandon, four of us built an AI filmmaking studio in four hours. The hard part was keeping one character the same person across a whole film.</description><pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;“You need discipline in life. If not discipline, motivation. If not motivation, at least be audacious.”&lt;/p&gt;
&lt;p&gt;I said that out loud on a Saturday morning and three people unfortunately believed me. So at the Google Developer Group Brooklyn hackathon at NYU Tandon, we looked at the four hour timer and decided to build an entire AI filmmaking studio. The first commit landed at 11:06. The deadline was 15:30.&lt;/p&gt;
&lt;h2&gt;Why I wanted this specifically&lt;/h2&gt;
&lt;p&gt;I have directed, written, or cut seven films. On a real set, continuity is somebody’s full time job. The tool I wanted was a continuity department.&lt;/p&gt;
&lt;h2&gt;The problem, stated plainly&lt;/h2&gt;
&lt;p&gt;AI has the memory of a goldfish with a film degree.&lt;/p&gt;
&lt;p&gt;Ask an image model for the same character twice and you get siblings. By scene 18 your lead has a new face, and the dialogue drifts too.&lt;/p&gt;
&lt;blockquote&gt;
Drift is caused by re-deriving.
&lt;/blockquote&gt;
&lt;p&gt;Summarize a character afresh for every prompt and you end up conditioning on a description nobody approved. So the rule: compile identity once, store it, inject it word for word. A 100 question interview compiles each character into a frozen Identity Card and Voice Card, and with fewer than 12 core answers the system refuses to write that character.&lt;/p&gt;
&lt;h2&gt;Board: shot 6 provably matches shot 1&lt;/h2&gt;
&lt;p&gt;Every frame is conditioned on one locked reference sheet. Then the face is cropped, embedded and scored against that sheet, and the score sits on the frame.&lt;/p&gt;
&lt;p&gt;The crop is the whole referee, and a spike at 13:07 proved it. Maya’s sheet against Maya’s three shots scored 0.39, 0.39 and 0.51. Against Ravi’s sheet, a different person, it scored &lt;span&gt;0.48&lt;/span&gt;, because both were grey three view sheets under flat light. A whole frame referee would have passed everything, looked authoritative on screen, and been worse than no referee at all.&lt;/p&gt;
&lt;p&gt;The voice bar sits at 0.62, between the best impostor line at 0.613 and real lines at 0.647 and up. A score that cannot be computed reads “no score”, because a referee that silently approves everything is the most expensive kind of theater.&lt;/p&gt;
&lt;h2&gt;Screenwriter: the twist that got spoiled on line three&lt;/h2&gt;
&lt;p&gt;Each character speaks through its own subagent. In the first run one read the shared scene brief and spoiled a twist on line three. The model did nothing wrong. The context did.&lt;/p&gt;
&lt;p&gt;So every character carries a knowledge horizon on a story graph, and one edge kind, &lt;code&gt;implies&lt;/code&gt;, does the work. Learning the route is cut implies the depot is losing a shift, which implies somebody is being moved.&lt;/p&gt;
&lt;h2&gt;13:20, and the list of things we cancelled&lt;/h2&gt;
&lt;p&gt;By 13:20 we had a design spec I was proud of and about 100 minutes left. It called for Cloud SQL, Terraform, CI/CD and an MCP server. All correct engineering for a project with a future. All worthless inside 100 minutes.&lt;/p&gt;
&lt;p&gt;So I wrote &lt;code&gt;NOW.md&lt;/code&gt;, which opened with “Cancelled. Do not start any of these today.” Retrieval became a cosine scan over about 50 chunks in &lt;span&gt;numpy&lt;/span&gt;, fused with lexical matching for names.&lt;/p&gt;
&lt;h2&gt;Four people, one repo, no merge hell&lt;/h2&gt;
&lt;p&gt;Each person owns one file and everyone pushes every 10 to 15 minutes. A half finished tab behind a disabled button is worth more than a perfect tab that never merges.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sahaj Mekala&lt;/strong&gt; took the board and authentication, &lt;strong&gt;Kyangchat Kapio&lt;/strong&gt; the character interview, &lt;strong&gt;Gaurav Poddar&lt;/strong&gt; location scouting, and I took the knowledge layer, the screenwriter, and the front and back ends around them.&lt;/p&gt;
&lt;h2&gt;The stack, and the honest scorecard&lt;/h2&gt;
&lt;p&gt;Gemini 2.5 Pro and Flash, Nano Banana for images and Vertex embeddings, behind one FastAPI service on Cloud Run. You can follow one shot request through it in &lt;a href=&quot;/architecture/magic-hour&quot;&gt;the drawing&lt;/a&gt;. A frame took about 30 seconds on a project shared with every team in the room, so every image was cached and committed. The version of your product that runs with the network unplugged is the version you are actually presenting.&lt;/p&gt;
&lt;p&gt;Not recommended. Would do again immediately.&lt;/p&gt;
&lt;p&gt;Compile the identity once. Store it. Inject it exactly as written. Measure every output against what you stored, and put the number where somebody can see it.&lt;/p&gt;
&lt;p&gt;Code: &lt;a href=&quot;https://github.com/SampreethAvvari/previz&quot;&gt;SampreethAvvari/previz&lt;/a&gt;. Start with &lt;code&gt;docs/NOW.md&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you make films, or you make tools for people who do, come find me.&lt;/p&gt;</content:encoded></item><item><title>Ten marketing sources joined by 6 AM and in the inbox by 6:30</title><link>https://sampreethavvari.com/posts/marketing-warehouse/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/marketing-warehouse/</guid><description>Our marketing numbers lived in five logins, and nothing joined an ad click to a booked patient. I built a BigQuery warehouse that makes that join every morning and mails it at 6:30.</description><pubDate>Fri, 02 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our marketing lead had a question that sounds like thirty seconds of work. Of the people who clicked a Google ad, how many called, how many came in for a consult, and how much treatment did they accept?&lt;/p&gt;
&lt;p&gt;In August the answer lived in five logins. Google Ads for clicks and spend, Microsoft Ads and Meta for the rest of the spend, CallRail for phone calls, and Monday, our CRM, where leads sit on one board and patients on three others, one per office. Each had its own date picker and its own idea of a lead. Answering took a spreadsheet and an afternoon.&lt;/p&gt;
&lt;p&gt;By October the ask had grown. The marketing lead and I wanted to ask Claude and get answers that say where every number came from, plus a morning email with one Excel tab per source.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A BigQuery warehouse in its own Google Cloud project. Google Ads arrives through Google’s managed Data Transfer. Everything else comes through ten small Python connectors, each a Cloud Run job: Microsoft Ads, Meta ads and organic pages, CallRail, Monday, GA4, Search Console, Google Business Profile, a weekly copy of Monday’s own export, and TapClicks, the reporting platform our ad agency uses.&lt;/p&gt;
&lt;p&gt;Data moves from raw to staging to marts. At 6 AM the pulls run, at 6:30 the email goes out, at 6:45 a job checks that every source landed, and every Monday Gemini takes a 22 question test against the marts. &lt;a href=&quot;/architecture/marketing-warehouse&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;August 4 was one long day. The design spec went in at 12:35 PM, and the first sync job ran in the cloud at 4:07 PM, three and a half hours later. The CallRail fix followed at 7:11 PM. The join fixes came after midnight: &lt;span&gt;1:37 AM&lt;/span&gt; and 2:44 AM on August 5. The first eval run, at 1:53 AM, scored 17 of 22 and failed its own 80 percent gate.&lt;/p&gt;
&lt;p&gt;TapClicks joined on September 25.&lt;/p&gt;
&lt;p&gt;October 21 brought TapClicks metrics, Performance Max, CallRail source and office on every call, a freshness view, and the morning email. The first test email went out at 3:15 PM, and the 6:30 email has gone out every morning since October 22. On October 27 GA4, Search Console and organic Facebook and Instagram moved to direct pulls. On October 29 Business Profile did too.&lt;/p&gt;
&lt;p&gt;When a marketing teammate asked which video ads hold attention, thumb stop and watch depth went into the creative mart on October 28 at 4:46 PM. A backfill of 270 days of Meta history ran from 4:56 to 5:43 PM that afternoon.&lt;/p&gt;
&lt;p&gt;I deploy the jobs by hand with gcloud scripts.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The join was already being made by hand.&lt;/strong&gt; My first lead to patient join matched on phone, then Google click ID, then email. The funnel said 96 percent of leads became patients. Blanks from Monday were matching each other, so I turned them into nulls. Then I checked which keys fired. Phone and click ID matched zero pairs. Meanwhile staff link each patient back to its lead with a Monday board relation. That link became the primary join, the old keys became fallbacks, and every row records which one fired:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;-- sql/marts/lead_to_patient_funnel.sql&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;WHEN&lt;/span&gt;&lt;span&gt; p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;linked_lead_id&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; l&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;item_id&lt;/span&gt;&lt;span&gt; THEN&lt;/span&gt;&lt;span&gt; &apos;direct_link&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;WHEN&lt;/span&gt;&lt;span&gt; l&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;phone&lt;/span&gt;&lt;span&gt; IS NOT NULL&lt;/span&gt;&lt;span&gt; AND&lt;/span&gt;&lt;span&gt; l&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;phone&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;phone&lt;/span&gt;&lt;span&gt; THEN&lt;/span&gt;&lt;span&gt; &apos;phone&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;WHEN&lt;/span&gt;&lt;span&gt; l&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;gclid&lt;/span&gt;&lt;span&gt; IS NOT NULL&lt;/span&gt;&lt;span&gt; AND&lt;/span&gt;&lt;span&gt; l&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;gclid&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;gclid&lt;/span&gt;&lt;span&gt; THEN&lt;/span&gt;&lt;span&gt; &apos;gclid&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;ELSE&lt;/span&gt;&lt;span&gt; &apos;email&apos;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Today 3,334 of 3,383 matches come from the staff’s own link and 49 from email. A weekly copy of Monday’s export, compared person by person, agrees on 3,371 patients, 99.3 percent.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A backfill that looked like missing history.&lt;/strong&gt; The first 13 month CallRail backfill returned 182 calls, all from the last week. The connector filtered with &lt;code&gt;created_after&lt;/code&gt;, which CallRail’s calls endpoint ignores without an error. Switching to &lt;code&gt;start_date&lt;/code&gt; brought back 4,877 calls reaching to June 2025.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Writing it for a model that reads it cold.&lt;/strong&gt; All 657 staging and marts columns carry a description written in the SQL file, so each redeploy writes it again. A data guide lists purpose, grain, keys and gotchas for 40 tables, next to 30 worked examples. Rows from TapClicks keep a source label, and the rule for Claude is a line that starts “TapClicks flag:” on any answer that uses one.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A connector subscription.&lt;/strong&gt; None of the services that copy ad data into a warehouse follow a click through a call and a CRM lead to a patient, and every source here has a free API.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TapClicks where a direct pull exists.&lt;/strong&gt; TapClicks is a copy of other platforms’ numbers. I pull GA4, Search Console, Business Profile and organic social straight from Google and Meta, and keep TapClicks for streaming TV delivery and older dates. Its call, review, comment and lead views are blocked in code before the job reads its credentials.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An office that is not ours.&lt;/strong&gt; The agency’s TapClicks account also carries an office in another city that shares the Hybridge name. I cut it from staging, the email and the connector.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Three people get the 6:30 email: our marketing lead, a marketing teammate and me. Each gets a summary and &lt;span&gt;11 source tabs&lt;/span&gt;, each saying where its rows came from and when they were pulled. The same three ask Claude through the &lt;a href=&quot;/posts/data-connector&quot;&gt;connector&lt;/a&gt;, which logged 469 tool calls from October 23 to 29. On October 29 all nine daily direct sources had landed by the afternoon, and the Monday eval scored 21 of 22 on October 26.&lt;/p&gt;
&lt;p&gt;Cost is not measured yet. The billing export for this project switched on October 28, 2026, so there is no measured month. I have no number for how long the old spreadsheet answer took or how often the email gets opened. Streaming TV delivery after early October waits on the agency loading the new platform into TapClicks.&lt;/p&gt;
&lt;p&gt;What I would keep: the &lt;code&gt;match_method&lt;/code&gt; column. When a number looks wrong, the first question is how the rows were joined, and the answer is one GROUP BY away.&lt;/p&gt;</content:encoded></item><item><title>Every new patient call graded, with the quote behind each score</title><link>https://sampreethavvari.com/posts/npc-coach/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/npc-coach/</guid><description>The old grader sent a number out of 60 and a Google Doc link. Mine has graded 690 real calls with a quote behind every score, a median 14 minutes after the call ends.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;A New Patient Coordinator, an NPC, takes the first phone call. The caller is often nervous and sometimes in pain, and that call decides whether they ever walk in. The practice founder wrote the playbook for it. The founder’s rule: the NPC is the patient’s “first advocate, not a clinician, not a salesperson,” and “correctness always beats conversion.”&lt;/p&gt;
&lt;p&gt;A grader already existed, built in n8n. Every hour it scored calls as a flat sum out of 60, wrote each result into a Google Doc named after the patient, shared it with the whole company and emailed a link. No database, so no NPC had a trend. No quote behind a score, so nobody could say why a call got a 47. If the workflow died halfway, the call was simply gone.&lt;/p&gt;
&lt;p&gt;The phone system also records everything: 6,670 recordings in October, most not new patients.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;NPC Coach grades every new patient call and puts the result in front of the NPC who took it. Every five minutes from 6 AM to 6 PM, a Cloud Run Job takes the files it has not seen from the phone system’s SFTP drop. Lines that are not NPC stations, call park recordings and calls under a minute stop there. Gemini transcribes the rest, and a strict Gemini Pro check asks whether this really is a new patient talking to an NPC before any grading.&lt;/p&gt;
&lt;p&gt;The grade is six criteria from 0 to 10, each with a quote from the transcript, plus three hard gates: did the NPC say who they are, get the patient’s name, and frame the source as a referral. The NPC gets the report by email, a summary goes out at 5 PM on weekdays, and a dashboard behind Google sign in holds every call and trend. &lt;a href=&quot;/architecture/npc-coach&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;First commit: June 2. On June 3 I worked from 2:13 AM to 8:10 PM on scoring, transcription, tagging and ingestion, each behind a port and written test first.&lt;/p&gt;
&lt;p&gt;The first batch of real calls ran just after midnight on June 18, sixteen days after the first commit. That night I took the score cap out at 3:05 AM, found the real cause of the “not gradeable” calls, and shipped the uncooperative caller flag at &lt;span&gt;4:49 AM&lt;/span&gt;. The poller job went live at 5:25. The next night brought the report email, recording playback, the 5 PM summary and the Pro relevance gate.&lt;/p&gt;
&lt;p&gt;A few days in, our COO asked, “is a 60.5 from one call or several?” By June 23 every average carried its call count. In July, NPCs disputed four grades; the founder ruled, and the rubric changed on July 23. On August 4 the founder ruled that NPCs may quote the cost range on the practice website, and that shipped the same afternoon as prompt 1.5.0.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A flag beside the score.&lt;/strong&gt; The first rubric capped any call that failed a hard gate at 49. On real calls that destroyed information: a warm, well sequenced call with one retail phrase collapsed to 49 and told the coach nothing else. Now the score is the plain 40/40/20 weighted sum and a failed gate is a loud red banner beside it. A call can read 84 and still be flagged. 125 of the 690 graded calls carry that banner.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;”Not gradeable” was a number format.&lt;/strong&gt; About 15 of the first 43 real new patient calls came back with no score. I assumed &lt;span&gt;garbled transcripts&lt;/span&gt;. The cause was smaller: Gemini sometimes wrote its confidence as 95 instead of 0.95, the schema allowed at most 1.0, and the whole verdict was thrown away. The fix coerces that field, records which field failed, and sends the error back for one repair try.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every report went out twice.&lt;/strong&gt; The trainer and an NPC both got two emails for every call. Cloud Scheduler fires every five minutes whether or not the last run finished, and a run was making about 7,700 SFTP stat calls, so runs overlapped and each graded and emailed the same new call. On July 23 the poller started skipping known file names before touching SFTP, and a Firestore lease let only one run work at a time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# src/processor/poll_lock.py&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;    self&lt;/span&gt;&lt;span&gt;._ref().create({&lt;/span&gt;&lt;span&gt;&quot;holder&quot;&lt;/span&gt;&lt;span&gt;: holder, &lt;/span&gt;&lt;span&gt;&quot;expires_at&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;._now() &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; self&lt;/span&gt;&lt;span&gt;._lease_s})&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; True&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;except&lt;/span&gt;&lt;span&gt; AlreadyExists:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    return&lt;/span&gt;&lt;span&gt; False&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create fails if the lease exists, so that one call is the whole lock. If Firestore is down it fails open: a duplicate email beats no grading.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Grading booking rate.&lt;/strong&gt; The rubric has no booking criterion. In the playbook’s words, a patient who books “just to get off the phone” is “a failure of advocacy, not a success.”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Letting the model do arithmetic.&lt;/strong&gt; Gemini returns six integers, three gate answers and an urgency state, and a Python function turns them into the score. Same verdict, same number. Moving scheduling from 20 to 30 percent is one line of code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The full design on day one.&lt;/strong&gt; The plan had queues between every stage. I wired one process instead, so real calls were graded on June 18. The queue Terraform is still in the repo, never applied.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Seven NPC lines are on the allow list. Each NPC gets a report for every graded call, their numbers colored against their own 30 day average. Leaders get the 5 PM summary, and since October 5 so does every NPC with a graded call that day. Since June 17 the poller has read 28,745 recordings and graded 690 calls; in October it graded 172, a median 14 minutes after the call ended. 34 graded calls have been pulled out of the averages.&lt;/p&gt;
&lt;p&gt;No cost yet: this project’s billing export only switched on Oct 28, 2026, so there is no measured month.&lt;/p&gt;
&lt;p&gt;I have not measured whether coaching moved the scores. The grading prompt changed four times on live data, so a raw before and after would compare different rulers. A design for call intelligence and insights is written and not built.&lt;/p&gt;
&lt;p&gt;What I would keep: a quote behind every number, the arithmetic in code, and when a job sends email, only one copy of it running.&lt;/p&gt;</content:encoded></item><item><title>Every patient record on one Denticon chart, linked without guessing</title><link>https://sampreethavvari.com/posts/patient-dashboard/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/patient-dashboard/</guid><description>Our coordinators had four tabs and four ids for one patient. I built a read only Denticon mirror, a registry the other apps share, and record links that prove a match before they write.</description><pubDate>Tue, 06 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;A treatment coordinator has a consult at nine. Before it starts the coordinator needs the patient’s intake answers, last week’s recorded consult, the estimate a colleague started, and whether surgery is booked. In August that meant four tabs on my dashboard, Denticon in another window, and a guess.&lt;/p&gt;
&lt;p&gt;The guess was the problem. Each system I had built named a patient its own way: a survey response id, an estimator patient number, a Zoom meeting, an email. The Denticon patient id sat in three of them as a hand typed field nothing checked. My plan from September 9 said it plainly: “so today it joins nothing.”&lt;/p&gt;
&lt;p&gt;So the tabs stayed apart on purpose. Merging two records because the names look alike is a made up match, and in a dental practice that puts one patient’s estimate on another patient’s chart.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;The CDF dashboard is the staff screen for every patient at our three offices. It opens on the day’s appointment book with that day’s intakes beside it. Click a patient and one profile holds the intake, the recorded consult and its note, the estimates, the appointments and uploads, joined on the Denticon id.&lt;/p&gt;
&lt;p&gt;Three pieces make that work. A sync service is the only thing holding Denticon credentials, and it keeps a read only mirror in Firestore. A small registry API serves that mirror to the estimator and the capacity planner, each with its own secret. And a record linker puts an outside record on a chart only when it can prove which one, and hands the rest to a person. &lt;a href=&quot;/architecture/patient-dashboard&quot;&gt;Open the drawing&lt;/a&gt;, and the registry has &lt;a href=&quot;/architecture/cdf-registry&quot;&gt;its own&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The dashboard went up on June 23 as one row per survey, built between 6:52 PM and 12:54 AM. On August 28 it moved into its own repo.&lt;/p&gt;
&lt;p&gt;Planet DDS confirmed our API access on September 9. The first Denticon commit landed at 12:36 AM on September 15. On September 21 the mirror went on a timer, and the same day the Denticon credentials came off the dashboard.&lt;/p&gt;
&lt;p&gt;On September 29, at &lt;span&gt;2:57 AM&lt;/span&gt; the sync service switched to the live practice. The capacity planner’s routes shipped at 4:58 AM, the first real webhooks found two faults, and the fix went out at 5:27 AM. That evening every patient got a profile, and between 6:44 PM and 1:33 AM record links and intake links went live. The backfill ran that night: 259 links, 0 errors.&lt;/p&gt;
&lt;p&gt;On October 15 the practice asked for a focus groups option on the experience survey. The survey shipped it at 1:00 PM and this dashboard’s filter at 1:10 PM.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Denticon says 200 when it ignored you.&lt;/strong&gt; The API ignores a query parameter it does not know and returns the whole table, so a misspelled filter looks fine. I transcribed the vendor’s portal, 77 operations, and proved each filter by watching &lt;code&gt;totalCount&lt;/code&gt; change. Fourteen made the list. Anything else throws before the request leaves.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The sync that had never run.&lt;/strong&gt; Putting the mirror on a timer on September 21 found four reasons it had never run by itself. The route was missing from the login allowlist, so the scheduler got a 307 to the sign in page and logged success. It only accepted a signed in person. It refreshed offices and providers and stopped. And every bearer secret carried a trailing &lt;span&gt;carriage return&lt;/span&gt;, so the freshness check answered 401 to its own secret. That afternoon the mirror went from 6.0 days behind Denticon to 2.6 minutes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A person’s decision always wins.&lt;/strong&gt; I had the first matcher attacked from three angles before it wrote anything. The worst case: a parent and adult child with the same name, the family email only on the parent’s chart. The child uses that email, the rule finds one chart and calls it certain. The review found 4 critical and 11 high distinct defects. What writes today was measured first: blind against consults whose chart the booking confirmed, the rules were right every time they picked, 453 of 453. Each write is one transaction that decides again on fresh reads, starting with who decided last:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// lib/links/store.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; async&lt;/span&gt;&lt;span&gt; function&lt;/span&gt;&lt;span&gt; applyMatcherDecision&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Firestore&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; MatcherWrite&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Promise&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;MatcherOutcome&lt;/span&gt;&lt;span&gt;&amp;gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; ref&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; db.&lt;/span&gt;&lt;span&gt;collection&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;LINKS&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;doc&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;linkDocId&lt;/span&gt;&lt;span&gt;(w.source, w.recordId));&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt; db.&lt;/span&gt;&lt;span&gt;runTransaction&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;tx&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    const&lt;/span&gt;&lt;span&gt; current&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; readLink&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; tx.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(ref)).&lt;/span&gt;&lt;span&gt;data&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; (current?.decidedBy &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; &apos;person&apos;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &apos;kept-person&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Asking patients for a date of birth.&lt;/strong&gt; It would make matching easier and add a field to a form patients fill in before meeting anyone. Matching uses email, name, office and booking instead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Giving other apps their own Denticon keys.&lt;/strong&gt; The capacity planner’s first plan kept Denticon secrets in its own project. I cut that. One service holds the credentials, everyone else reads the registry, and revoking one caller’s secret does not blind the other.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Letting the email rule write.&lt;/strong&gt; Email alone is the rule the family case breaks. It runs in shadow mode, recording what it would have linked, until at least 50 of its calls are settled by a coordinator with zero disagreements.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It is built for coordinators and office staff at all three offices, and Patients is the page it opens on. Intakes link on their own when a name and a booking agree, which on September 30 covered 81 of 103. I have not measured how often staff open a profile or what time it saves. The billing export for this project only switched on October 28, so there is no measured month of cost yet.&lt;/p&gt;
&lt;p&gt;Still open: a Send intake button that mails a survey link already bound to the chart. Clinical notes cannot be written through the Denticon API, so they still reach the chart by paste.&lt;/p&gt;
&lt;p&gt;What I would keep: almost every bad bug here looked like success. A 200 with the whole table, a 307 logged as fine, an hourly sweep printing &lt;code&gt;swept=0/0&lt;/code&gt; while every call failed. I found each one by running the thing and asking what it had actually done.&lt;/p&gt;</content:encoded></item><item><title>A report renderer that keeps trying until every email lands</title><link>https://sampreethavvari.com/posts/pdf-renderer/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/pdf-renderer/</guid><description>Every patient survey becomes a summary and a doctor report, emailed to the patient and the office. I built one renderer that three callers share, records each step, and finishes the job on retry.</description><pubDate>Fri, 19 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;A patient fills in our intake survey on an office tablet before a consult. The practice needs two documents from it: a one page summary for the patient and a doctor report for the office team. The summary goes to the patient by email, and both go to that office’s list.&lt;/p&gt;
&lt;p&gt;In August my own comment in the code described what kept happening: “the patient got their summary, the clinic got nothing, and no error surfaced anywhere.”&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A Fastify service on Cloud Run that renders each PDF in a &lt;span&gt;fresh headless Chromium&lt;/span&gt;, stores it, and delivers it. Three callers share it: a survey submit becomes one named Cloud Task, staff on the CDF dashboard press Regenerate or upload an old assessment PDF, and Cloud Scheduler calls an hourly sweep. When an intake is done it tells the CDF, which puts it on the patient’s chart. &lt;a href=&quot;/architecture/pdf-renderer&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first renderer commit went in at 11:00 PM on June 19, and the service was running on Cloud Run at 11:07 PM.&lt;/p&gt;
&lt;p&gt;August 24 was the delivery day. Between 10:33 AM and 2:09 PM I stopped losing office emails, sized the service for a browser per render, added the hourly sweep, moved the work inside the request, and put every render on a queue. It was all live by 2:28 PM.&lt;/p&gt;
&lt;p&gt;On August 25 a practice leader asked for three changes to the experience survey, one of them its own office lists, separate from intake. A post surgery complaint should not land with the people who book consultations. It was live on August 27 at 10:01 AM.&lt;/p&gt;
&lt;p&gt;The CDF handshake was live at 1:21 AM on September 30, and the email owner check at 3:37 PM on October 26.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Work that ran after the reply.&lt;/strong&gt; &lt;code&gt;/render&lt;/code&gt; used to answer and then deliver. Cloud Run throttles the CPU once a response is out, so the delivery crawled, and died when the instance was recycled. Now the work is awaited inside the request, which holds the instance with full CPU for the 60 to 90 seconds it takes. Concurrency is 8 an instance, because each render forks a browser of about 300 MB.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retries that never mail twice.&lt;/strong&gt; Cloud Tasks retries until it gets a 2xx, so a survey can run through &lt;code&gt;/render&lt;/code&gt; more than once. Every run first reads what already went out:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// apps/pdf-renderer/src/server.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; prior&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; ((&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; db.&lt;/span&gt;&lt;span&gt;collection&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;reports&apos;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;doc&lt;/span&gt;&lt;span&gt;(responseId).&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;()).&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;() &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; {}) &lt;/span&gt;&lt;span&gt;as&lt;/span&gt;&lt;span&gt; Record&lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;any&lt;/span&gt;&lt;span&gt;&amp;gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; patientAlreadySent&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; prior.delivery?.patient?.status &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; &apos;sent&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While any step is missing it answers 500 and the queue comes back, ten tries from 10 seconds to 5 minutes apart. Behind the queue, the hourly sweep reads seven days of surveys and leaves the newest 75 minutes alone, so it never races a retry.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Whose email is it.&lt;/strong&gt; A typed address can belong to someone else, so before a patient email goes out, it is looked up in the Denticon mirror. When it is on file only for other patients, the patient email is held and the office email says why. The practice’s own inboxes are never held, and an address on no chart goes out, because a new patient’s email is often not in Denticon yet.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A risk score in the patient’s copy.&lt;/strong&gt; That number is for the doctor to explain in the room.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A shared secret with the CDF.&lt;/strong&gt; The intake event carries a Google identity token for the renderer’s own account, and only an answer of ack counts as delivered.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Letting the CDF hold up an email.&lt;/strong&gt; The event tries twice, then rides the queue for about 25 minutes, then sends one alert with ids and the error. Nothing about it can delay, fail or repeat a patient’s or an office’s email.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Every intake and experience survey from the three offices goes through it, and so does the CDF dashboard’s Regenerate button.&lt;/p&gt;
&lt;p&gt;Its cost is not measured: the billing export for its project only switched on Oct 28, 2026. I have not counted deliveries from the logs either.&lt;/p&gt;
&lt;p&gt;What I would keep: record each step as it lands, and make every retry read that record first.&lt;/p&gt;</content:encoded></item><item><title>No more green checks on consults that never emailed</title><link>https://sampreethavvari.com/posts/pipeline-ghosting/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/pipeline-ghosting/</guid><description>Runs went green while emails never sent. I moved the run row to the top, made every failure retry or turn amber, and added logs anyone can read.</description><pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;The consult grader had been live for about a week, and it was a polite liar. A run could show complete while the email never went out and the master Sheet never got its row. Some consults never showed up at all.&lt;/p&gt;
&lt;p&gt;The complaints came in two shapes. “I ran it but no email came.” And errors nobody could see in All Runs, the dashboard table. I was rerunning consults by hand and could not always say why the first run had failed.&lt;/p&gt;
&lt;p&gt;The people reading that dashboard are treatment coordinators (TCs) and our CEO. “Check Cloud Logging” is not an answer they can use. The job was to make every failure visible, make every temporary failure retry itself, and make the dashboard agree with what really happened.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Four changes to the same pipeline. Every transcript event now gets a run row before any gate, so nothing can vanish. One status legend everywhere: green done, amber needs attention, red failed, blue running, and green only when both the email and the Sheet row landed. Two layers of retry: in the call for Google API blips, and a sweep every 5 minutes for runs that failed anyway. And a Logs tab on every run, with an Easy view in plain English and a Developer view with the audit events and the raw record. Behind all of it, a reconciler checks the Sheet after every status change. &lt;a href=&quot;/architecture/doc-coach&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first fix went in on May 27 at 5:14 PM: run rows for every event, TCs with more than one mailbox, one status legend.&lt;/p&gt;
&lt;p&gt;On May 29 the work ran from 2:31 PM to 10:58 PM. The fix for “I ran it but no email came” was one flipped default: the Rerun button’s send email flag had defaulted to off. A one shot script found 13 runs that had scored but never emailed and resent them. The same script replayed 4 missing Sheet rows from the scorecards still in Drive; the other 9 had no saved scorecard and needed a full rerun. Then the reconciler, retries with backoff, the broken pipe fix at 9:24 PM, and the Logs tab at 10:58 PM.&lt;/p&gt;
&lt;p&gt;June 1 added the 5 minute sweep. Between 1:34 AM and &lt;span&gt;2:20 AM&lt;/span&gt; on June 2 came delete as a tombstone so the sweep stops touching a meeting, repair the delivery without scoring again, escalate when the budget runs out, and a Mark complete button for when a human knows better.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The run row came too late.&lt;/strong&gt; It used to be created deep in the webhook flow, after the host check, the dedupe claim and the Zoom download. Anything dropped before that left no trace. Two TCs had moved mailboxes from one company domain to the other, the host allowlist only knew their old addresses, and their consults were dropped without a trace. Now the row comes first, every drop marks it failed with a reason and a fix, and a TC can have several addresses.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Errors that are not HTTP errors.&lt;/strong&gt; The retry helper caught Google’s HTTP errors and OAuth refresh errors. A &lt;span&gt;BrokenPipeError&lt;/span&gt; from the TLS socket never becomes an HTTP response, so it slipped past, and a bare except swallowed it: the consult scored cleanly and the Sheet row was never written. In June an SSL error killed a report card run the same way, because it is an OSError but not a ConnectionError. Now the transient set is explicit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# zoom-transcript-pipeline/app/retry.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;TRANSIENT_CONNECTION_ERRORS&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    BrokenPipeError&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;ConnectionAbortedError&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ConnectionRefusedError&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;    ssl.SSLError, http.client.HTTPException,&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;Three attempts with 1 and 2 seconds of backoff plus a little jitter, then it raises, so the run turns amber.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A flag set before the work finished.&lt;/strong&gt; The record said “Sheet appended” when the append had not happened, and sometimes the opposite, when an error came after the append succeeded. The reconciler reads the Sheet after every status change. Flag says yes and the row is missing: flip it and show amber. Flag says no and the row is there: flip it and show green. It never appends or deletes rows on its own, because that is the direction it cannot prove safe.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Retrying everything.&lt;/strong&gt; The sweep only touches failure codes a rerun can fix. A host it does not know, a filtered meeting, a duplicate or an unresolved doctor is a config question, so it waits for a person. Each run gets 3 tries. The code comment says it plainly: burning model tokens on a broken meeting is worse than leaving the red row.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Throwing away the last good run.&lt;/strong&gt; A rerun that succeeds replaces the original row and dedupes the Sheet, so there is one row per consult. A rerun that fails leaves the original alone as the known good baseline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A queue I did not need yet.&lt;/strong&gt; The work runs as a background task after the 202. The scaling notes say when to move to Cloud Tasks: around five consults at once per instance, or Zoom retries climbing. At about 80 consults a month, a queue would be one more system to watch.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Every transcript event leaves a row, and every failure path in the pipeline marks that row with a code and a plain English fix. Since May the audit log shows 9 scoring failures, 10 note failures and 17 Drive upload failures, each a row someone could see.&lt;/p&gt;
&lt;p&gt;The sweep, the repair path and the reconciler still run every day. The Logs tab now walks ten stages, since the chart note became one.&lt;/p&gt;
&lt;p&gt;What is not measured: I do not have a count of manual reruns before and after, so I will not claim a drop.&lt;/p&gt;
&lt;p&gt;What I would keep: never set a flag before the work it describes is done, and check it against the real thing after.&lt;/p&gt;</content:encoded></item><item><title>How one engineer runs 45 services for a three office practice</title><link>https://sampreethavvari.com/posts/platform/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/platform/</guid><description>A dental implant practice with three offices and its own lab runs on 45 live services I built. Here is the map, every service on it, and the patterns that keep them running.</description><pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Hybridge is a dental implant practice with offices in Rochester, Buffalo and Syracuse, and its own lab. Its day runs on systems that do not talk to each other: Denticon for charts and bookings, Monday for leads and patient boards, Zoom for consults, a phone system that records calls, the lab’s case and scan systems, and a lot of spreadsheets.&lt;/p&gt;
&lt;p&gt;Every team brought me one problem. Our VP of finance wanted every website inquiry inside our own cloud, with none lost. Coordinators had four tabs and four ids for one patient. The operations lead planned doctors in an Excel workbook of twenty pasted weeks. Our marketing lead’s simplest question lived in five logins.&lt;/p&gt;
&lt;p&gt;Each problem is small. Solving each one where the data already lives is how a practice this size ends up with 45 services.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;45 live services in 14 Google Cloud projects, &lt;span&gt;listed with gcloud on October 29&lt;/span&gt;. Three shared services carry the most weight: the Denticon sync keeps a copy of Denticon, the CDF registry serves it to other apps, and the PDF renderer turns surveys into reports. A fourth, the request relay, lets six apps file feature requests into one list. &lt;a href=&quot;/architecture/platform&quot;&gt;Open the drawing&lt;/a&gt;, or browse &lt;a href=&quot;/architecture&quot;&gt;every drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Patient forms and surveys&lt;/h2&gt;
&lt;p&gt;Drawing: &lt;a href=&quot;/architecture/hybridge-forms&quot;&gt;Hybridge Forms&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Public forms.&lt;/strong&gt; Shows the inquiry forms on four Hybridge websites and saves each send with its delivery jobs in one write. One instance stays warm.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forms dashboard.&lt;/strong&gt; Staff build and publish forms, read inquiries and map fields to Monday columns, behind Google sign in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delivery worker.&lt;/strong&gt; Puts each inquiry on the Monday Leads board and emails the copies within seconds. A queue task wakes it on every send, and a sweep runs every minute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nightly checks.&lt;/strong&gt; At 2 AM loads every form, sends a test, and moves any inquiry stuck in the backup bucket into the database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Patient surveys.&lt;/strong&gt; The intake survey before a consult and the experience survey after, on an office tablet or from a link. Each submit becomes one named task for the renderer.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The shared core&lt;/h2&gt;
&lt;p&gt;Drawings: &lt;a href=&quot;/architecture/denticon-sync&quot;&gt;Denticon sync&lt;/a&gt;, &lt;a href=&quot;/architecture/cdf-registry&quot;&gt;CDF registry&lt;/a&gt;, &lt;a href=&quot;/architecture/pdf-renderer&quot;&gt;PDF renderer&lt;/a&gt;, &lt;a href=&quot;/architecture/patient-dashboard&quot;&gt;CDF dashboard&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Denticon sync.&lt;/strong&gt; Keeps the Firestore mirror of Denticon from webhooks, a worker every ten minutes and a 4 AM poll. The only service with Denticon credentials.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CDF dashboard and registry.&lt;/strong&gt; The staff screen with one row per patient, and the read only routes the estimator and the planner call with their own secrets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PDF renderer.&lt;/strong&gt; Turns a finished survey into a patient summary and a doctor report, mails them, and tells the CDF. The survey queue, the CDF and an hourly sweep call it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Estimator and planner&lt;/h2&gt;
&lt;p&gt;Drawings: &lt;a href=&quot;/architecture/treatment-estimator&quot;&gt;Treatment estimator&lt;/a&gt;, &lt;a href=&quot;/architecture/capacity-planner&quot;&gt;Capacity planner&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Treatment estimator.&lt;/strong&gt; Coordinators build and present estimates and treatment sequences, and open on the day’s consults from the registry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Capacity planner.&lt;/strong&gt; Shows leadership who is where across the three offices. A scheduler job reads the book every minute, and a 7 AM email goes out on weekdays.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Launchpad&lt;/h2&gt;
&lt;p&gt;Drawings: &lt;a href=&quot;/architecture/launchpad&quot;&gt;Launchpad&lt;/a&gt;, &lt;a href=&quot;/architecture/feature-requests&quot;&gt;request relay&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Launchpad.&lt;/strong&gt; One page that opens every Hybridge app, with a Work board for four leaders and every app’s feature requests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Request relay.&lt;/strong&gt; Launchpad’s image deployed a second time without IAP. Six apps call it from their servers with their own Google ID tokens.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Marketing data&lt;/h2&gt;
&lt;p&gt;Drawings: &lt;a href=&quot;/architecture/marketing-warehouse&quot;&gt;Marketing warehouse&lt;/a&gt;, &lt;a href=&quot;/architecture/data-connector&quot;&gt;Data connector&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nine source pulls.&lt;/strong&gt; At 6 AM one Cloud Run job per source copies recent days from CallRail, Meta ads, Meta organic, Microsoft Ads, TapClicks, Monday, GA4, Search Console and Business Profile into BigQuery.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Google Ads transfer.&lt;/strong&gt; A managed BigQuery transfer copies the Google Ads tables daily.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monday backup.&lt;/strong&gt; Wednesdays at 7 AM loads the board exports staff drop in Drive, to check the API copy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;View rebuild.&lt;/strong&gt; At 6:20 rebuilds each person’s data views, so nobody waits on a first question.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Morning email.&lt;/strong&gt; At 6:30 sends the Marketing Data email with one Excel tab per source.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Health check.&lt;/strong&gt; At 6:45 checks that every source landed and no number jumped oddly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Question quiz.&lt;/strong&gt; Mondays at 8 AM asks a model questions with known answers, to check the warehouse still answers them right.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data connector.&lt;/strong&gt; Lets approved staff ask Claude about marketing data with their own Google sign in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Status page.&lt;/strong&gt; One page that shows whether each feed ran.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Coaching&lt;/h2&gt;
&lt;p&gt;Drawings: &lt;a href=&quot;/architecture/doc-coach&quot;&gt;Doc Coach&lt;/a&gt;, &lt;a href=&quot;/architecture/npc-coach&quot;&gt;NPC Coach&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Doc Coach.&lt;/strong&gt; When a Zoom consult recording finishes, Gemini scores the doctor against a written rubric and drafts a note, and the report goes to the doctor and the coordinator. Zoom’s webhook wakes it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NPC call poller.&lt;/strong&gt; Every five minutes in the work day picks up new phone recordings, keeps new patient calls and grades them against the playbook.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NPC daily summary.&lt;/strong&gt; At 5 PM on weekdays emails each coordinator’s average and what to work on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NPC Coach dashboard.&lt;/strong&gt; Managers and coordinators browse graded calls, transcripts and recordings.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The lab&lt;/h2&gt;
&lt;p&gt;Drawings: &lt;a href=&quot;/architecture/cbct-scan-validator&quot;&gt;CBCT validator&lt;/a&gt;, &lt;a href=&quot;/architecture/doctor-portal&quot;&gt;Doctor Portal&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CBCT validator.&lt;/strong&gt; When a scan zip lands in storage, a model checks its quality, and a PDF verdict is written and emailed. A storage event wakes it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SFTP drop box.&lt;/strong&gt; A small VM where the lab’s scan system leaves scans and picks up verdicts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Training data job.&lt;/strong&gt; At 6 AM turns labeled scans in Drive into training data for the model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CBCT review app.&lt;/strong&gt; The testing team uploads a study, records the true verdict, and sees each model’s hits and misses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Doctor Portal.&lt;/strong&gt; Where outside doctors will send an Rx and follow their cases, in preview on sample data.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Money and operations&lt;/h2&gt;
&lt;p&gt;Drawings: &lt;a href=&quot;/architecture/cowork-dashboard&quot;&gt;Cowork Dashboard&lt;/a&gt;, &lt;a href=&quot;/architecture/daily-sales-report&quot;&gt;Daily Sales Report&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cowork ingest.&lt;/strong&gt; Turns the weekly Monday exports in Drive and a daily read of the three patient boards into one snapshot. A scheduler checks it every five minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cowork Dashboard.&lt;/strong&gt; Leads, cases, cash flow and pace, computed in the browser from that snapshot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DSR pipeline.&lt;/strong&gt; Weekday mornings reads the lab’s sales and new starts reports, updates the warehouse, forecasts, checks the numbers and publishes the Daily Sales Report.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DSR dashboard.&lt;/strong&gt; The report as sent, past runs and the monthly inputs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reconciliation.&lt;/strong&gt; Staff upload a day’s schedule, ledger and notes exports and get back only the patients whose billing and notes disagree.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Websites&lt;/h2&gt;
&lt;p&gt;Drawings: &lt;a href=&quot;/architecture/hybridge-website&quot;&gt;Patient website&lt;/a&gt;, &lt;a href=&quot;/architecture/hybridge-lab&quot;&gt;Lab website&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Patient website.&lt;/strong&gt; The new patient site in preview, with a tool that shows a visitor a new smile on a photo of their own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lab website.&lt;/strong&gt; The new site for doctors who send cases to the lab, in preview.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;One holder of each key.&lt;/strong&gt; Denticon credentials live on the sync service and nowhere else. The estimator and the planner each hold one bearer for the registry, so revoking one never blinds the other.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Machines sign their calls.&lt;/strong&gt; Every scheduler job, queue task and app to app call carries a token. The relay checks each app’s Google ID token and lets an app file only about itself. The renderer and the CDF trade identity tokens instead of a shared secret.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Zero instances, except where speed matters.&lt;/strong&gt; The estimator, the planner, Launchpad and the forms worker scale to zero. The Denticon webhook receiver keeps one instance warm because Denticon wants a reply in ten seconds, and the public forms keep one as well.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A sweep behind every queue.&lt;/strong&gt; Forms deliveries retry at one minute, ten minutes and an hour. The renderer’s queue has an hourly sweep behind it. Denticon’s webhooks have a 4 AM poll. Gemini calls retry and fall back through newer models.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Writing to Denticon.&lt;/strong&gt; Every app reads; staff make every real change in Denticon themselves.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Replacing the tools staff already use.&lt;/strong&gt; Denticon stays the record and Monday stays the board. The services read from them and, for leads, add one item to Monday.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A second build for a second service.&lt;/strong&gt; The CDF dashboard and the Denticon sync share one image, and so do Launchpad and its relay.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Coordinators, doctors, office directors, leadership, marketing and the lab use these services, and so does every patient who fills in a form or a survey.&lt;/p&gt;
&lt;p&gt;The six projects the billing export measured cost $132 from October 1 to 28: CBCT validation $61.76, the estimator $33.88, the capacity planner $27.07, and Forms, Launchpad and the Doctor Portal $9.00 together. The other eight projects hold the CDF, marketing data, Cowork, the Daily Sales Report, Doc Coach, NPC Coach, Reconciliation and the websites. Their billing export only switched on Oct 28, 2026, so they have no measured month yet. I have not counted requests from the logs either.&lt;/p&gt;
&lt;p&gt;What I would keep: one holder for each key, and a sweep behind every queue.&lt;/p&gt;</content:encoded></item><item><title>Three exports checked by hand every day became one short list</title><link>https://sampreethavvari.com/posts/reconciliation/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/reconciliation/</guid><description>Our insurance team read the schedule, ledger and notes against each other by hand every day. I built an engine that does the reading and returns only the patients who disagree.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;A dental visit leaves three records. The schedule says who came in. The production ledger says what was billed. The clinical notes say what happened in the chair. Our insurance team read all three side by side, every day, to confirm that each patient seen was documented and billed under the right doctor.&lt;/p&gt;
&lt;p&gt;Across the team that came to about 70 hours a week of reading by hand. Most patients were fine. The ones that were not mattered: a crown written up and never billed is lost money, and a charge with no note behind it is an audit problem.&lt;/p&gt;
&lt;p&gt;The group also runs two practice systems. Elmwood, the general practice, is on Eaglesoft. The implant offices are on Denticon. Same job, two very different files.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Reconciliation, a web app. A reviewer picks the practice tab, uploads the day’s schedule, ledger and notes, and gets summary cards back. Click a card and each flagged patient opens with their schedule, ledger and note rows, down to tooth, surface, provider and amount. Each flag has a Mark fixed button, every run is saved so a past day reopens in one click, and the report can go out by email.&lt;/p&gt;
&lt;p&gt;Underneath is a pure Python engine with no network, no database and no clock. Sign in, upload, history and email all wrap it. &lt;a href=&quot;/architecture/reconciliation&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The first commit, at 5:23 AM on June 16, was the design spec. The engine followed 13 minutes later with a golden test against a real day of exports. The web app went in at noon and the first live revision at 2:09 PM. Two days later Denticon got its own tab.&lt;/p&gt;
&lt;p&gt;After that the work became a loop with the reviewers. They sent back patients the tool got wrong, I traced each one, fixed the reading, added a test and shipped. Some of those landed late: 12:00 AM on June 19, and &lt;span&gt;4:28 AM on July 17&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;On July 24 a reviewer wrote down the rule for which doctor bills a no exam visit. v1.13.0 went live at 4:52 PM that day, two minutes after its commit.&lt;/p&gt;
&lt;p&gt;By September 1 it was on v1.19.0. After that last round, one reviewer’s August 12 Denticon day came back with zero flags.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Notes are written by people.&lt;/strong&gt; The ledger says which doctor billed each line, and the note has to agree. Notes name doctors by nickname, by first name, by initials, with “Dr” fused to the name and no space, and with typos. Two doctors share a last name, so a last name alone proves nothing. The registry maps every provider number to one dentist and all of their names, and matches within a spelling mistake or two:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# engine/providers.py&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; len&lt;/span&gt;&lt;span&gt;(name) &lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt; 5&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; False&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;maxd &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; 1&lt;/span&gt;&lt;span&gt; if&lt;/span&gt;&lt;span&gt; len&lt;/span&gt;&lt;span&gt;(name) &lt;/span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt; 8&lt;/span&gt;&lt;span&gt; else&lt;/span&gt;&lt;span&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; _lev(token, name, maxd) &lt;/span&gt;&lt;span&gt;&amp;lt;=&lt;/span&gt;&lt;span&gt; maxd&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Names under five letters must match exactly, because too many ordinary words are &lt;span&gt;one edit away&lt;/span&gt;. The engine also learned what is not evidence. “Intra/Extra Oral Exam:” is a hygiene screening heading, and “Next visit: exam” is a plan for another day. Both are stripped before it looks for a doctor exam.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bad input that looks like good data.&lt;/strong&gt; One Monday run showed 85 patients billed with no note. The patients were fine. The notes export had defaulted to the previous Friday. Now the engine compares the dates inside both files and warns on the report and in the email subject. Separately, Eaglesoft leaves line breaks in note text unquoted, so a long note split across rows and lost its bottom half, which is where doctors sign. The parser rejoins the rows. On one test day that cleared 4 false flags and cut provider mismatches from 21 to 13.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A slash that means upper or lower.&lt;/strong&gt; At the implant offices a delivery code carries the arch in where its slash sits. DEL/P is the lower provisional and DELP/ the upper. The first rule read a template label as an answer of “both”, so every single arch delivery flagged. Now the note’s code, its upper or lower answer and the ledger’s codes are compared arch to arch.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A model in the verdict.&lt;/strong&gt; A Gemini matcher that lines up each billed procedure with the note by tooth, surface and wording is built and stays out of the verdict. Every flag today comes from a rule a reviewer can read, and the terminology list for each practice is still waiting on their confirmation. When it goes in, it will sit on top of the deterministic checks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Matching on names.&lt;/strong&gt; Every file joins on patient id. The Eaglesoft notes export has no id column, so the engine reads the id off the end of the sort name. An appointment with no id goes to a person to confirm.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Guessing past a bad file.&lt;/strong&gt; A missing column stops the run with an error that names it, and the wrong Denticon report gets an error that says which report to export. In a tool people use to sign off on billing, an error is the safer answer.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Reviewers at both practices ran their real days through it from June through August, and their last round of notes shipped as v1.19.0 on September 1. One round listed eight Rochester patients the tool had wrongly passed, and v1.10.0 flags all eight.&lt;/p&gt;
&lt;p&gt;I have not measured how often it runs now, or the time it saves. The 70 hours a week is the team’s own count of the before, and I have no after number. The billing export for its project only switched on October 28, 2026, so there is no measured month of cost yet. The Gemini matcher is the open piece.&lt;/p&gt;
&lt;p&gt;What I would keep: write each office rule into a test the day a reviewer says it, and keep the engine a pure function, so any flag can be replayed from the same three files.&lt;/p&gt;</content:encoded></item><item><title>Movie twins for 330,975 users after Spark kept losing nodes</title><link>https://sampreethavvari.com/posts/recsys-spark-bigdata/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/recsys-spark-bigdata/</guid><description>A three person NYU capstone on 33.8 million MovieLens ratings: Spark ALS for recommendations, and my MinHash notebook that found movie twins after Spark&apos;s similarity join kept losing workers.</description><pubDate>Sat, 18 May 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;The Big Data capstone at NYU gave every team the same two asks on MovieLens. First, find the 100 pairs of users with the most similar taste, judged only by which movies they rated, and show the pairs are real. Second, build a recommender with Spark’s ALS, compare it with a popularity baseline, and score both on the top 100 movies per user.&lt;/p&gt;
&lt;p&gt;The full set was 33,832,162 ratings from 330,975 users across 86,537 movies. Comparing every user with every other user means &lt;span&gt;about 55 billion pairs&lt;/span&gt;. That rules out the obvious loop.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;We were a team of three. Teammates built the Spark side: a per user time split written to Parquet on HDFS, the popularity baseline, ALS and the ranking metrics. My part was the movie twins.&lt;/p&gt;
&lt;p&gt;Each user becomes the set of movies they rated. datasketch turns that set into &lt;span&gt;30 MinHash numbers&lt;/span&gt;, and an LSH index with 6 bands drops users whose numbers agree into the same buckets, so a query only compares a user with likely matches. A correlation check then tests whether the twins really rate alike. &lt;a href=&quot;/architecture/recsys-spark-bigdata&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The twins search started in Spark and stalled there. A teammate’s commit titled “segmentation last try” went in at 1:21 AM on May 12, 2024. My notebook landed at 8:05 PM on May 14 and was merged two minutes later. The report followed at 11:43 PM the next night.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Spark kept losing its workers.&lt;/strong&gt; Spark’s MinHashLSH with an approximate similarity join worked on the small dataset. On the full one, Dataproc reported lost nodes and killed workers. Dropping users with fewer than 20 ratings did not help, and neither did smaller hash settings. Even a tenth of the data still lost nodes. So I moved the search to one Python process on NYU’s Greene cluster with datasketch and pandas, and it finished: 10 seconds to insert every user and 27 seconds to query them all.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# Capstone Proj/Customer-Segmentation/pandas-greene/large.ipynb&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;lsh &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; MinHashLSH(&lt;/span&gt;&lt;span&gt;threshold&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;threshold, &lt;/span&gt;&lt;span&gt;num_perm&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;num_perm,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                 params&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;(num_bands, num_perm &lt;/span&gt;&lt;span&gt;//&lt;/span&gt;&lt;span&gt; num_bands))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; user, mi &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; tqdm(minhashes.items()):&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    lsh.insert(user, mi)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Picking the knobs by the result.&lt;/strong&gt; We tried 15 to 500 permutations and 2 to 8 bands. More permutations were slower and not better. 500 took 33 minutes to build signatures and gave the top 100 an average correlation of 0.33 at best. 30 took 6 minutes 19 seconds and scored 0.73. That setting returned 22,215,721 candidate pairs, and the top 100 are the pairs with the most movies in common.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Proving the twins are real.&lt;/strong&gt; A hash match says nothing about taste. For each pair I took the movies both users rated and computed the Pearson correlation of their ratings. The top 100 averaged 0.7348. One hundred random pairs averaged 0.0323.&lt;/p&gt;
&lt;h2&gt;What I learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Measure the shape of the work.&lt;/strong&gt; The data was 33.8 million rows, but the job was 330,975 sets of movie ids, and those fit in one machine’s memory. A cluster is a cost you pay. Here one process paid less.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Give every result a control.&lt;/strong&gt; On the 600 user small set, the same settings found 151 pairs, and their correlation barely beat random, 0.22 against 0.15. Without the random pairs, both runs would have looked like a success.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It was a course project, and nothing runs now. The movie twins section of our report is my notebook’s output, and the notebook holds the full list of 100 pairs. The recommender side, ALS against the popularity baseline, is my teammates’ work and is written up in the same report.&lt;/p&gt;
&lt;p&gt;What I would keep: look at the shape of the work before reaching for a cluster, and check every clever result against a dumb one.&lt;/p&gt;
&lt;p&gt;Full project: &lt;a href=&quot;https://github.com/SampreethAvvari/BigData&quot;&gt;Customer Segmentation and Recommendation System&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Augmentation took our capped ResNet to 97.12% on CIFAR-10</title><link>https://sampreethavvari.com/posts/resnet-under-5m/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/resnet-under-5m/</guid><description>A class contest: CIFAR-10, a ResNet, a hard cap of 5 million parameters. Flip and crop topped out at 93.76%. Augmentation, which adds zero parameters, took us to 97.12%.</description><pubDate>Sun, 08 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;In our deep learning class the final became a contest. Build the best CIFAR-10 classifier you can, keep it a ResNet, and keep it under 5 million trainable parameters. The course scored every team on 10,000 hidden images that nobody had labels for. We were a team of three.&lt;/p&gt;
&lt;p&gt;The cap takes away the usual lever. The official ResNet18 has over 11 million parameters and scored 88.56% in our own runs. So the job was to find accuracy that does not cost parameters.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A PyTorch training pipeline in notebooks. Every training image is augmented as it loads, batches of 128 go through a ResNet with four stages of three blocks, and SGD runs on a cosine schedule for 300 epochs. A cell counts trainable parameters before the first epoch. Each epoch is scored on the 10,000 labeled test images, and the final model writes a CSV of labels for the 10,000 hidden ones. Every run went into a shared results sheet. &lt;a href=&quot;/architecture/resnet-under-5m&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The repo’s history runs April 11 to 24, 2024, with three of us pushing into it. The busiest stretch came just after midnight on April 13, from 12:05 to 12:50 AM, while we merged notebooks and model weights into one repo. The report went in at 6:10 PM that day.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Tuning hit a ceiling.&lt;/strong&gt; Our first runs used flip and crop with a step schedule, and they landed between 79% and 91% whatever we changed. A ResNet34 with 11.7 million parameters, more than twice the cap, scored 90%. Bigger was not working, and it was against the rules anyway. Adadelta on a cosine schedule got the best flip and crop run to 93.76%.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The data was the lever.&lt;/strong&gt; If the model is as big as the rules allow and the hyperparameters are dialed in, what is left? The cap was on parameters, and augmentation costs none. Each image becomes a slightly different image every time it loads, and a &lt;span&gt;cat rotated a little&lt;/span&gt; is still a cat. The report lines it up: flip and crop, 93.76%. Add AutoAugment, 95.34%. Add a perspective warp, 97.12%.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# Notebooks/ResNet26_97_best_model_1.ipynb, cell 2&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;transforms.AutoAugment(torchvision.transforms.AutoAugmentPolicy.&lt;/span&gt;&lt;span&gt;CIFAR10&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;transforms.RandomCrop(&lt;/span&gt;&lt;span&gt;32&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;padding&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;4&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;transforms.RandomHorizontalFlip(&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;0.5&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;transforms.RandomPerspective(&lt;/span&gt;&lt;span&gt;distortion_scale&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;0.6&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;0.5&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Spending the budget.&lt;/strong&gt; With the data settled, the parameters went where they helped. Channels of 128, 100, 164 and 200 came out of sweeping widths under the cap. The plain 16, 32, 64, 128 ladder scored 79%, and ResNet34’s 3, 4, 6, 3 blocks scored 91% while needing 5.3 million. The final model has four stages of three blocks, 3 by 3 kernels, 1 by 1 shortcuts and 4,978,810 parameters, with &lt;span&gt;21,190 to spare&lt;/span&gt;.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Heavier machinery.&lt;/strong&gt; Teacher student setups and exotic architectures were on the table, and we had only half heard of some of them. We read papers and open source CIFAR-10 repos instead, kept the plain residual block, and put the effort into data and training.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Trusting one test score.&lt;/strong&gt; The repo keeps two finalists. ResNet26 scored 97.12% on the labeled test set. A slimmer ResNet24, with 3, 3, 2, 3 blocks and 4,969,776 parameters, scored 96.82% there, and it is the one the repo names best on unseen data. The test set we could see was never the one that counted.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;It was coursework, finished in April 2024. The report, the notebooks and the trained weights are public in the repo, along with the results sheet of every run. The ResNet26 notebook’s last epoch read 9,712 of 10,000 test images right.&lt;/p&gt;
&lt;p&gt;What I would keep: when the obvious lever is capped, step back and find the one nobody is pulling. Ours was data, and it cost zero parameters.&lt;/p&gt;</content:encoded></item><item><title>A quoting tool live in 14.5 hours, where a saved price stays put</title><link>https://sampreethavvari.com/posts/treatment-estimator/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/treatment-estimator/</guid><description>Coordinators quoted from one shared price list with no office prices or tiers. The estimator I built writes the price onto every saved line, and shipped 14.5 hours after its first commit.</description><pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Our treatment coordinators quote full arch implant cases with the patient in the chair. They did it in an Apps Script page on top of a Google Sheet. It had one price list with no column for the office, so Rochester, Buffalo and Syracuse shared every number. It had no tiers, though our senior coordinator’s own pricing sheet did: a first implant at one price, a second lower, a third lower again. And if the page could not read the sheet, it quietly fell back to prices typed into its own code.&lt;/p&gt;
&lt;p&gt;The printout guarantees every fee for six months. So the first goal in the spec was plain: any estimate can be reproduced six months later at the exact prices quoted, even after an office changes its prices.&lt;/p&gt;
&lt;p&gt;The coordinators named the rest. Listing courtesies and general anesthesia under each scenario was, in our senior coordinator’s email, “a major need for this to be successful.”&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;A web app on Cloud Run where a coordinator builds an estimate in a drag and drop workspace: options for each arch, phases, and scenarios that pair an upper with a lower. A pricing engine prices every line from that office’s published price book, and the save writes four things onto each line: price, tier, label and price book version. Totals, the PDF and the Excel read those back.&lt;/p&gt;
&lt;p&gt;Each office has its own versioned price book, and only an admin publishes a change. The app opens on the day’s consults, read from the CDF, my service that holds the practice system’s credentials. &lt;a href=&quot;/architecture/treatment-estimator&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;I wrote the spec and nine architecture decisions on May 6. The first commit went in at 4:26 PM on May 7. The pricing paths landed at 2:21 AM, the engine tests reached full coverage at 2:32 AM, and I kept going until 6:54 AM, when a session fix became v1.0.0, 14.5 hours after the first commit.&lt;/p&gt;
&lt;p&gt;The trial ran at Rochester first, with coordinators trying to break it. Our senior coordinator got six pricing questions, each with a safe default already coded so nothing waited. The answers came on May 20, and the coordinator’s rule for implant tiers, one running count across the whole arch, shipped as v1.7.0 on June 4.&lt;/p&gt;
&lt;p&gt;On September 29 every screen moved into the practice’s design in one day.&lt;/p&gt;
&lt;p&gt;Most of it came from the people quoting. On August 19 our senior coordinator asked to price a crown on an implant already in the mouth. That commit went in at &lt;span&gt;4:58 AM&lt;/span&gt; and shipped as v1.14.0 five minutes later. On October 28 a coordinator could not add a courtesy to an estimate already presented, and the only way around it was editing the Excel by hand. Reopen to edit shipped as v1.56.0 that day.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A quote has to survive a price change.&lt;/strong&gt; Price books change faster than six months, so the price is written down where it was decided. Every saved line carries its price in &lt;span&gt;integer cents&lt;/span&gt; and the price book version it came from, and every reader uses those. Presenting also freezes the whole estimate as a snapshot. An earlier version of this post said a Postgres trigger enforced the freeze. The design called for one, and it never shipped. The freeze lives in the stored lines and the code that reads them. Saving an option again reprices it from the current book on purpose, the only way a saved number moves.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The arch had to stop being a question.&lt;/strong&gt; Coordinators picked Upper, Lower or Both before building anything. One picked upper only, built a lower option in the workspace, which always shows both arches, and could not reach the summary. Since September 28 the arch is whatever the saved options add up to, set inside the same transaction as every option save and delete.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reopening without breaking payments.&lt;/strong&gt; Copying the estimate into a new revision would have been simple and wrong, because payments point at option and line ids. So reopening moves the live rows to the next revision and leaves the presented version frozen. Presented again, an estimate with payments goes straight back to accepted. The status change is guarded, so two clicks cannot both move the rows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// app/_actions/estimate.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;and&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  eq&lt;/span&gt;&lt;span&gt;(estimate.id, est.id),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  eq&lt;/span&gt;&lt;span&gt;(estimate.status, from),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  eq&lt;/span&gt;&lt;span&gt;(estimate.currentRevisionNo, oldRev),&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;The second click finds nothing to update and stops.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A rules engine in the database.&lt;/strong&gt; The flow of a consult lives in TypeScript with tests, and only prices live in rows. Rules in JSON would turn every change to the clinical flow into a data migration no diff explains.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Talking to the practice system, or faking it.&lt;/strong&gt; The day’s consults come from the CDF with a secret of their own, and the CDF reads a patient lookup back with a different one, so revoking one never blinds the other. Without the secret, production shows an empty day and says why. Sample patients that look real would be worse than a blank screen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A staging environment nobody used.&lt;/strong&gt; The design had one. Its deploy workflow went on day two, and its database sat stopped, still billing, until October 29. The billing export shows the hourly cost falling by $0.0053 from then.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Coordinators quote from it for all three offices. A read only audit on August 27 counted 75 live estimates in production. I have no usage count since then.&lt;/p&gt;
&lt;p&gt;It cost $34 from October 1 to 28, measured from the billing export. The always on database is $33.20 of that. The app cost 14 cents, because it scales to zero between consults.&lt;/p&gt;
&lt;p&gt;I have not measured time per quote, before or after, or how often a printed number used to be wrong.&lt;/p&gt;
&lt;p&gt;What I would keep: write the money down where it was decided, and when other records point at your rows, move them.&lt;/p&gt;</content:encoded></item><item><title>Visit plans generated from the practice&apos;s own masters</title><link>https://sampreethavvari.com/posts/treatment-sequence/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/treatment-sequence/</guid><description>Coordinators retyped every plan of visits into Word. Now the estimate picks one of eleven transcribed practice masters and a builder edits it. The engine ships as a committed snapshot, with no registry.</description><pubDate>Sun, 04 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;It is the end of a full arch consult and the patient has said yes. The coordinator opens Word, finds the right master in a shared Drive folder, copies it, types in the patient’s name and fills the blanks: which teeth, who does the work, how long each visit holds the chair.&lt;/p&gt;
&lt;p&gt;Then the plan changes. The case needs one more visit after visit 3, so every visit after it needs a new number. Every date is the previous date plus a wait, so every date after the new visit is wrong too. The coordinator fixes them one at a time, and nothing checks the work.&lt;/p&gt;
&lt;p&gt;By then the &lt;a href=&quot;/posts/treatment-estimator&quot;&gt;estimate&lt;/a&gt; already knew the arch, the treatment path, the teeth and the implant staging. The plan was being retyped from data one screen away.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;Two packages in their own repo, and no app. sequence-core holds eleven of the practice’s masters, transcribed word for word as data. It picks the master that fits the estimate, fills in the real teeth, projects the dates from the waits, and writes the Word file. sequence-ui is a three pane builder: the visits, a flowchart with each gap sized by its real wait, and a live preview of the document. Time is kept in &lt;span&gt;15 minute units&lt;/span&gt;, because every value in the masters divides evenly.&lt;/p&gt;
&lt;p&gt;The estimator carries a built copy of both, committed in its own repo and stamped with the commit it came from. &lt;a href=&quot;/architecture/treatment-sequence&quot;&gt;Open the drawing&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The sprint&lt;/h2&gt;
&lt;p&gt;The repo’s history starts at 11:35 AM on August 27 with the core package. The first run against real estimates found the worst bug, fixed at 12:03 PM. Estimator v1.27.0 shipped the builder and the Word export at 4:05 PM, and v1.28.0 added saving, versions and the PDF at 7:04 PM.&lt;/p&gt;
&lt;p&gt;That noon fix also closed a gap a real estimate had hit: an upper Hybridge in two stages with a lower Hybridge in one. The practice had a master for it, so the same commit transcribed it.&lt;/p&gt;
&lt;p&gt;The second round came on September 29, when the estimator moved into the practice’s design. The last change here went in at 10:44 PM for the Word file, and the estimator took it at 11:06 PM. It reaches production only through an estimator release.&lt;/p&gt;
&lt;h2&gt;The hard parts&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Empty lists read as no staging.&lt;/strong&gt; The first version passed its tests, then sent every Hybridge case to a manual build. I had worked out staging from the teeth: implant teeth listed as two stage meant a two stage case. A full arch option decides staging for the whole arch and lists no single teeth, so both lists were empty, the code read that as no staging, and every Hybridge master demands one. The fix was to stop guessing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// packages/core/src/generate.ts&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; hasStaging&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; options.&lt;/span&gt;&lt;span&gt;some&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; o.implantStaging &lt;/span&gt;&lt;span&gt;!==&lt;/span&gt;&lt;span&gt; null&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; anyTwo&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; options.&lt;/span&gt;&lt;span&gt;some&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; o.implantStaging?.mode &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; &apos;two&apos;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; staging&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &apos;one&apos;&lt;/span&gt;&lt;span&gt; |&lt;/span&gt;&lt;span&gt; &apos;two&apos;&lt;/span&gt;&lt;span&gt; |&lt;/span&gt;&lt;span&gt; null&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; !&lt;/span&gt;&lt;span&gt;hasStaging &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; null&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span&gt; anyTwo &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; &apos;two&apos;&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span&gt; &apos;one&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Staging is now a mode the option states, pinned by tests.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A copy that never refreshed.&lt;/strong&gt; I started with pnpm’s &lt;code&gt;file:&lt;/code&gt; protocol, which copies the folder once and copies again only when the dependency spec changes. The spec never changes. So node_modules kept serving the old build while the repo showed the new one, and nothing said a word. &lt;code&gt;link:&lt;/code&gt; points into the vendored folder, so a refresh is live the moment it lands. &lt;span&gt;Same files, different honesty.&lt;/span&gt; The vendor script now checks that node_modules serves the new stamp and fails if it does not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Masters are transcribed whole.&lt;/strong&gt; I could have glued two single arch masters together. The arches run on different clocks: the lower is finished at visit 6, before the upper surgery at visit 8. Glued plans get that order wrong in a way that looks fine on paper. A test pins the order. When the practice has already written the document, I copy it.&lt;/p&gt;
&lt;h2&gt;What I said no to&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A model that writes the plan.&lt;/strong&gt; A treatment sequence is a clinical document, and the practice already wrote the right ones. So generation picks a master and never invents a visit. A procedure with no visit prints in a yellow open items block, a plan with no master opens empty, and a wait like “As needed” stops the dates instead of guessing one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A package registry.&lt;/strong&gt; Publishing would need a write credential in this repo and a read credential everywhere the estimator installs, including the Docker build. The repo’s note says it plainly: “There is no auth step to get wrong at 6pm before a deploy.” The cost is real: nothing goes red when a consumer falls behind, and both repos carry a file marked TEMPORARY that says so.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Five tables for a plan.&lt;/strong&gt; Each save is one immutable JSON version, because nothing queries inside the steps. A unique index on plan and version number makes the second of two racing saves fail instead of overwrite. There is no foreign key to the options either, since editing an estimate deletes and recreates them.&lt;/p&gt;
&lt;h2&gt;Where it landed&lt;/h2&gt;
&lt;p&gt;Coordinators open a plan from any estimate, edit it in the builder, and download Word or PDF of what is on screen. A read only audit tries every scenario and option of every live estimate. Run against production on August 27: 75 estimates, 271 attempts, 263 matched a validated master, 8 opened empty, 0 errors. All 8 were plans that mix Hybridge and dentures. The practice has masters for those; they are not transcribed yet.&lt;/p&gt;
&lt;p&gt;It has no bill of its own. It runs inside the estimator, which cost $34 for October 1 to 28. I have not counted how many plans coordinators have saved since.&lt;/p&gt;
&lt;p&gt;The drift the TEMPORARY file warns about has already happened once: the capacity planner carries an older snapshot, to borrow one popover component. Automating the refresh is the next job, before the CDF patient dashboard starts using the packages too.&lt;/p&gt;
&lt;p&gt;What I would keep: when the clinic has already written the answer, transcribe it, and make the code admit what it does not know.&lt;/p&gt;</content:encoded></item><item><title>What 45 services cost to run for a three office dental practice</title><link>https://sampreethavvari.com/posts/what-it-costs/</link><guid isPermaLink="true">https://sampreethavvari.com/posts/what-it-costs/</guid><description>Six measured Google Cloud projects for a dental practice cost $131.71 from October 1 to 28. Two always on databases are the biggest lines. Here is the table and what drives it.</description><pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;How much does it cost to run 45 services on Google Cloud?&lt;/h2&gt;
&lt;p&gt;The six Hybridge projects with a measured month cost $131.71 on Google Cloud from October 1 to 28, 2026, net of credits. Most of it goes to the parts that never sleep: two small Postgres databases, one VM and one warm instance came to $76.55.&lt;/p&gt;
&lt;p&gt;Hybridge is a dental implant practice with offices in Rochester, Buffalo and Syracuse and its own lab. I run 45 live services for it across 14 Google Cloud projects, mapped in &lt;a href=&quot;/posts/platform&quot;&gt;the platform post&lt;/a&gt;. Six of those projects have a measured month. The other eight do not yet, because their billing export only switched on October 28, 2026.&lt;/p&gt;
&lt;h2&gt;What did each project cost in October?&lt;/h2&gt;
&lt;p&gt;Net of credits, in US dollars, October 1 to 28, from the standard billing export:&lt;/p&gt;





















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Project&lt;/th&gt;&lt;th&gt;What runs there&lt;/th&gt;&lt;th&gt;Oct 1 to 28&lt;/th&gt;&lt;th&gt;Biggest line item&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;/architecture/cbct-scan-validator&quot;&gt;CBCT scan validator&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Scan checker, review app, training data jobs, SFTP VM&lt;/td&gt;&lt;td&gt;$61.76&lt;/td&gt;&lt;td&gt;Cloud Run jobs CPU, $30.91&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;/architecture/treatment-estimator&quot;&gt;Treatment estimator&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Estimator app and its Postgres&lt;/td&gt;&lt;td&gt;$33.88&lt;/td&gt;&lt;td&gt;Cloud SQL instance, $23.45&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;/architecture/capacity-planner&quot;&gt;Capacity planner&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Planner app, minute sync and its Postgres&lt;/td&gt;&lt;td&gt;$27.07&lt;/td&gt;&lt;td&gt;Cloud SQL instance, $23.45&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;/architecture/hybridge-forms&quot;&gt;Hybridge Forms&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Public forms, dashboard, delivery worker, nightly checks&lt;/td&gt;&lt;td&gt;$8.62&lt;/td&gt;&lt;td&gt;Warm instance CPU, $4.76&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;/architecture/launchpad&quot;&gt;Launchpad&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Board, request relay, the retired June board&lt;/td&gt;&lt;td&gt;$0.32&lt;/td&gt;&lt;td&gt;Secret Manager, $0.20&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;/architecture/doctor-portal&quot;&gt;Doctor Portal&lt;/a&gt;&lt;/td&gt;&lt;td&gt;Portal on sample data, a parked duplicate list&lt;/td&gt;&lt;td&gt;$0.06&lt;/td&gt;&lt;td&gt;Secret Manager, $0.05&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Six projects&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;$131.71&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Forms is a partial month, since its first cost landed on October 6. The Doctor Portal went live on the 28th, a day that was still loading when I queried, so its own month is not in this number.&lt;/p&gt;
&lt;h2&gt;Why is the database most of the bill?&lt;/h2&gt;
&lt;p&gt;A Cloud SQL instance bills every hour it exists. The apps beside it bill only while they answer a request.&lt;/p&gt;
&lt;p&gt;The estimator’s Cloud SQL came to $33.20 of its $33.88, and $23.45 of that is the small instance alone. The planner’s came to $24.98 of $27.07: the same $23.45 instance plus $1.53 of storage. For that the planner gets managed Postgres with daily backups and point in time recovery.&lt;/p&gt;
&lt;p&gt;The apps themselves cost cents. The estimator’s Cloud Run was $0.14 for the month, because it scales to zero between consults. &lt;a href=&quot;/architecture/treatment-estimator&quot;&gt;Open the estimator’s drawing&lt;/a&gt; to see the database next to the app.&lt;/p&gt;
&lt;h2&gt;What does a sync every minute cost?&lt;/h2&gt;
&lt;p&gt;All of the planner’s Cloud Run time from October 1 to 28 came to &lt;span&gt;$1.29&lt;/span&gt;. That covers the sync Cloud Scheduler fires every minute and every page people opened. The scheduler job itself cost $0.09.&lt;/p&gt;
&lt;p&gt;It stays small because most minutes move only the rows that changed, with a full read at least once an hour to heal anything a delta missed. Open boards ask a tiny endpoint whether anything changed before they offer a refresh. &lt;a href=&quot;/architecture/capacity-planner&quot;&gt;Open the planner’s drawing&lt;/a&gt; and press Replay to watch the minute sync run.&lt;/p&gt;
&lt;h2&gt;When is one warm instance worth paying for?&lt;/h2&gt;
&lt;p&gt;When a person outside the company should never wait. Hybridge Forms keeps one public instance warm, so the first patient of the morning never waits on a cold start. That instance was $7.14 of the project’s $8.62. Requests across the public forms, the dashboard and the delivery worker came to $0.56, and the nightly check job to $0.03.&lt;/p&gt;
&lt;p&gt;No other service in these six projects shows a warm instance charge. Launchpad’s whole project cost $0.32, most of it the retired June board’s secrets and sync job, and request time for its three services came to under two cents. The &lt;a href=&quot;/architecture/denticon-sync&quot;&gt;Denticon sync&lt;/a&gt; also keeps one instance warm, because Denticon wants a webhook answered within ten seconds. Its project is one of the eight not measured yet.&lt;/p&gt;
&lt;h2&gt;What does an AI app cost to run?&lt;/h2&gt;
&lt;p&gt;The one AI system with a measured month is the &lt;a href=&quot;/architecture/cbct-scan-validator&quot;&gt;CBCT scan validator&lt;/a&gt;, which flags unusable dental CT scans before the design team opens a case. It cost $61.76.&lt;/p&gt;
&lt;p&gt;Scoring a scan is the cheap part. A frozen dental segmentation encoder compiled for CPU runs on Cloud Run and scales to zero, and all the CBCT services together, the validator and the review app included, used $0.68 of Cloud Run. Training runs on my laptop’s GPU, so model training adds nothing to this bill.&lt;/p&gt;
&lt;p&gt;The money goes to the parts around the model. Cloud Run jobs that turn labeled scans into training data cost $45.54. The main one woke every five minutes until October 28 and now runs once a day at 6 AM; the drop is not measured yet. The always on SFTP VM, where the lab’s case system leaves scans and picks up verdicts, cost $11.23.&lt;/p&gt;
&lt;p&gt;The apps that call Gemini, including Doc Coach, NPC Coach, the patient survey reports and the Daily Sales Report, sit in projects whose export switched on October 28. Their model spend is not measured yet.&lt;/p&gt;
&lt;h2&gt;What costs more than the compute?&lt;/h2&gt;
&lt;p&gt;In three of the six projects, Secret Manager cost more than Cloud Run. The estimator’s secrets came to $0.39 against $0.14 of Cloud Run. Launchpad’s came to $0.20 against $0.04. The Doctor Portal project’s came to $0.05 against less than a cent.&lt;/p&gt;
&lt;p&gt;At this size, an app that scales to zero costs less than the secrets it reads.&lt;/p&gt;
&lt;h2&gt;How were these numbers measured?&lt;/h2&gt;
&lt;p&gt;From Google Cloud’s standard billing export in BigQuery, with read only SELECT queries on October 29, 2026. Net is cost plus credits, in US dollars, by Pacific usage day, which is the billing console’s day. October 28 was still loading when I queried, so its last hours are missing. As a check, September summed the same way came within five cents of the invoice month.&lt;/p&gt;
&lt;p&gt;The standard export carries no service name for Cloud Run, so where a project runs several services the figure covers all of them. Tax posts separately, and October’s had not posted yet.&lt;/p&gt;
&lt;p&gt;Not measured yet: the projects that hold the CDF and patient surveys, marketing data, the Cowork Dashboard, the Daily Sales Report, Doc Coach, NPC Coach, Reconciliation and the websites. Their billing export switched on October 28, 2026 and had delivered no rows when I checked, so there is no measured month for them.&lt;/p&gt;
&lt;p&gt;What I would keep: scale to zero on everything that can sleep, and keep something on all the time only where a database or a waiting person needs it.&lt;/p&gt;</content:encoded></item></channel></rss>