<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: TEJAS _</title>
    <description>The latest articles on DEV Community by TEJAS _ (@jx777).</description>
    <link>https://dev.to/jx777</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3796482%2F67728b15-bdc0-41e2-b649-6d332a1839e5.jpg</url>
      <title>DEV Community: TEJAS _</title>
      <link>https://dev.to/jx777</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jx777"/>
    <language>en</language>
    <item>
      <title>ProofCheck: checking UPI payment screenshots against the bank statement, on your own laptop</title>
      <dc:creator>TEJAS _</dc:creator>
      <pubDate>Tue, 13 Oct 2026 19:49:49 +0000</pubDate>
      <link>https://dev.to/jx777/proofcheck-checking-upi-payment-screenshots-against-the-bank-statement-on-your-own-laptop-4hgl</link>
      <guid>https://dev.to/jx777/proofcheck-checking-upi-payment-screenshots-against-the-bank-statement-on-your-own-laptop-4hgl</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs9xa4zop5s98d1m4d772.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs9xa4zop5s98d1m4d772.png" alt="ProofCheck's report on the demo data: verified payments, missing ones, an edited amount, a duplicate, and who has paid" width="800" height="727"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;If you've ever paid a tuition fee, a society maintenance bill or a home baker in India, you know how it goes. You pay by UPI, take a screenshot, send it on WhatsApp. Done.&lt;/p&gt;

&lt;p&gt;Now think about the person on the other end. A tuition teacher with 40 students gets 40 screenshots a month, at random times, buried in the rest of her WhatsApp. At some point she sits down with her bank statement and ticks them off one by one, reading 12-digit reference numbers off a phone screen.&lt;/p&gt;

&lt;p&gt;And she can't fully trust any of them. A screenshot is just a picture. It can be edited. It can be of a payment that failed. It can be last month's screenshot sent again. Fake payment screenshots are a common enough scam that banks and payment companies keep publishing guides telling shopkeepers to check their account, not the screenshot.&lt;/p&gt;

&lt;p&gt;That check is the whole tool. ProofCheck takes a folder of payment screenshots and a bank statement export, reads the UPI reference number (the UTR) off each screenshot, and looks for it in the statement. Every screenshot ends up as one of these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Verified&lt;/td&gt;
&lt;td&gt;The UTR is in the statement and the amount matches. The money came in.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amount differs&lt;/td&gt;
&lt;td&gt;The UTR is there, but the screenshot shows a different amount.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not in statement&lt;/td&gt;
&lt;td&gt;No such UTR. Could be fake, could be a payment that hasn't settled yet.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate&lt;/td&gt;
&lt;td&gt;Another screenshot already used this UTR.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outside dates&lt;/td&gt;
&lt;td&gt;The screenshot is dated after the statement ends. Check a newer statement.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unreadable&lt;/td&gt;
&lt;td&gt;Couldn't find a UTR. You'll have to look at this one yourself.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You'll notice it never says "fake". I went back and forth on that. A missing UTR is suspicious, but a payment made an hour ago is missing too, and accusing a parent of fraud over a settlement delay is a good way to lose a student.&lt;/p&gt;

&lt;p&gt;Give it a small roster as well (who owes how much, and who usually pays for them) and you get the list a teacher actually cares about on the 5th of the month: who has paid, who paid part, who only sent a screenshot, and who hasn't paid at all. Money that arrived without any screenshot still counts, because the statement is the source of truth, not WhatsApp.&lt;/p&gt;

&lt;p&gt;So who would pay for this? Anyone collecting the same payments every month into a personal account and matching them by hand. Tuition classes are the obvious case. Housing society treasurers are another: a 120-flat society has 120 maintenance payments to match every month, often in a spreadsheet. The amounts are small, so the tool has to cost almost nothing to run, and that's a big part of why it's built on open models.&lt;/p&gt;

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

&lt;p&gt;The demo data is a made-up tuition class collecting October fees. There are eight screenshots, and between them they cover every case: payments that went through, one that never arrived, a failed payment sent as proof, a screenshot where ₹200 was edited to ₹2,000, and the same screenshot sent twice for two kids.&lt;/p&gt;

&lt;p&gt;Results show up row by row while it's still reading:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5lcs64p8w1kdqocn6tk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5lcs64p8w1kdqocn6tk.png" alt="Rows appearing while the screenshots are still being read" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On my laptop (a GTX 1650 with 4 GB of VRAM, nothing fancy) it got all 8 screenshots and all 8 roster lines right. With the model already loaded, the run took about 94 seconds, so roughly 12 seconds a screenshot.&lt;/p&gt;

&lt;p&gt;What that does and doesn't prove: the demo data is synthetic, so it shows the pipeline works end to end. It says nothing yet about how well it reads real screenshots from real phones.&lt;/p&gt;

&lt;p&gt;About those demo screenshots. They come from a fake app I called "Demo Pay", with SAMPLE - NOT A REAL PAYMENT stamped across them. It felt wrong to build a tool that catches fake payment screenshots and then ship a folder of convincing fake payment screenshots with it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzucxam4bem484hxa4bmu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzucxam4bem484hxa4bmu.png" alt="A demo screenshot of a failed payment, watermarked as sample data" width="720" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to run it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma3:4b
&lt;span class="nb"&gt;cd &lt;/span&gt;frontend &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm ci &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ..
&lt;span class="nb"&gt;cd &lt;/span&gt;backend &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; uv run proofcheck   &lt;span class="c"&gt;# then open http://127.0.0.1:8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Surge77" rel="noopener noreferrer"&gt;
        Surge77
      &lt;/a&gt; / &lt;a href="https://github.com/Surge77/proofcheck" rel="noopener noreferrer"&gt;
        proofcheck
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Check UPI payment screenshots against your bank statement with local open-weight models (Gemma + RapidOCR). Your statement never leaves your laptop.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;ProofCheck&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Check UPI payment screenshots against your bank statement, on your own laptop.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tuition teachers, housing-society treasurers, home bakers and tiffin services collect payments
by UPI into a personal account. The "proof" arrives as a screenshot on WhatsApp, and every month
someone matches dozens of them against the bank statement by hand. Fake payment screenshots are a
common scam, and they look exactly like real ones.&lt;/p&gt;
&lt;p&gt;ProofCheck reads each screenshot with local open-weight models, finds its 12-digit UPI reference
(UTR), and looks for that reference in your bank statement. It then tells you, in plain words
who really paid.&lt;/p&gt;
&lt;p&gt;Nothing is uploaded anywhere. The models run on your machine, uploads stay in memory, and your
bank statement never leaves your laptop.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/Surge77/proofcheck/docs/report.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FSurge77%2Fproofcheck%2FHEAD%2Fdocs%2Freport.png" alt="ProofCheck report on the demo data: 3 verified, 2 not in statement, 1 amount differs, 1 duplicate, 1 outside dates, and who has paid"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it tells you&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Verified&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The UTR is in your statement with the same amount. The money arrived.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Amount differs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The UTR is there, but the screenshot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Surge77/proofcheck" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;screenshot ──► RapidOCR ─────► exact characters ─┐
     │                                           ├─► cross-check ─► receipt (high / low confidence)
     └───────► Gemma 3 4B ───► what they mean ───┘                       │
bank statement (CSV / XLS / XLSX) ──► credits with UTR, payer, UPI id ──► reconcile ─► report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard part is reading the screenshot. A typical payment screen has a phone number, a transaction ID, a date and the UTR all in one place, and a phone number with the 91 prefix is also 12 digits.&lt;/p&gt;

&lt;p&gt;I ended up using two open models and making them check each other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/RapidAI/RapidOCR" rel="noopener noreferrer"&gt;RapidOCR&lt;/a&gt;, which runs PaddleOCR's models on onnxruntime, reads the text exactly, character by character. It has no idea which number is the UTR, though.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma 3 4B&lt;/a&gt;, running locally in Ollama, looks at the same screenshot and understands it. It can tell the UTR from the phone number, and "Payment Successful" from "Payment Failed". What it can't always do is read twelve small digits perfectly.&lt;/p&gt;

&lt;p&gt;So neither of them gets the final say. When Gemma says "this is the UTR", ProofCheck only accepts it if OCR saw exactly the same digits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_fuse_utr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vision_utr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;seen_by_ocr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;utr_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vision_utr&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen_by_ocr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;          &lt;span class="c1"&gt;# both models agree
&lt;/span&gt;    &lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_ocr_utr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# a single labelled OCR candidate, or nothing
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The vision model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s UTR was not confirmed by OCR; please check it.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Could not tell which number is the UPI reference.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The amount, the payment status and the payer's name go through the same kind of check. A screenshot only gets "high confidence" when both models agree and the screen itself says the payment went through. Anything else gets flagged for a person to look at, with the reason written out.&lt;/p&gt;

&lt;p&gt;The models only read. Deciding whether something is verified, a duplicate or missing is ordinary Python with tests around it. I didn't want an LLM making the call on whether someone paid.&lt;/p&gt;

&lt;p&gt;Bank statements were their own small adventure. There's no UTR column. Each bank stuffs it into a free-text description, and each one does it differently: SBI writes &lt;code&gt;UPI/CR/&amp;lt;utr&amp;gt;/&amp;lt;name&amp;gt;/...&lt;/code&gt;, HDFC writes &lt;code&gt;UPI-&amp;lt;name&amp;gt;-&amp;lt;upi id&amp;gt;-&amp;lt;ifsc&amp;gt;-&amp;lt;utr&amp;gt;&lt;/code&gt;. ProofCheck works out the bank from the header row, skips the address and account-number lines at the top, and reads CSV, XLSX and the old XLS format that some banks still hand out.&lt;/p&gt;

&lt;p&gt;Around that sits a FastAPI backend that streams results back as they're ready, a React and Tailwind frontend, and &lt;code&gt;Decimal&lt;/code&gt; everywhere money shows up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things that bit me
&lt;/h3&gt;

&lt;p&gt;I'd written "nothing is written to disk" in the README before checking it, and it wasn't true. I ran a security review on the upload code, and the first finding was that FastAPI's file uploads let Starlette save anything over 1 MB to a temp file. Most screenshots and bank statements are over 1 MB. I swapped in a parser that keeps everything in memory and added a test that fails if Python's &lt;code&gt;tempfile&lt;/code&gt; is ever touched. The review found 10 problems in total, including a DNS rebinding hole against localhost. They're all fixed now, but that first one stung.&lt;/p&gt;

&lt;p&gt;Screenshots can carry prompt injection. Text inside an image can nudge a vision model into calling a failed payment a success. That's why the status and the payer name also have to match what OCR read, not just the UTR.&lt;/p&gt;

&lt;p&gt;Then there was capital I versus lowercase l. Gemma read "Meera Iyer" correctly, OCR read "Meera lyer", and my strict name check threw out the right answer. I only caught it by running the whole thing on realistic data, which is a decent argument for doing that early.&lt;/p&gt;

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

&lt;p&gt;Without open models, this project doesn't really work.&lt;/p&gt;

&lt;p&gt;The input is someone's bank statement plus other people's names, UPI IDs and payment history. I wouldn't upload that to a third-party API, and I don't think a tuition teacher should either. With open weights everything stays on the laptop. The OCR models come bundled inside the Python package, Gemma runs in Ollama on localhost, and ProofCheck refuses to start if the Ollama URL points at another machine.&lt;/p&gt;

&lt;p&gt;Then there's the cost. Every screenshot goes through two models. With a paid vision API that's a charge per screenshot, every month, for a customer who's paying very little. Locally, once the models are downloaded, the next screenshot costs nothing.&lt;/p&gt;

&lt;p&gt;The cross-check depends on it too. I can run a dedicated OCR model and a vision-language model side by side and compare their answers because both run on my machine. A single hosted endpoint would give me one answer and nothing to check it against.&lt;/p&gt;

&lt;p&gt;And once it's set up it works offline. No API key, no rate limits, no pricing page that can change under me.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Best Use of Gemma: Gemma 3 4B runs locally in Ollama and reads every payment screenshot. ProofCheck checks its answers against OCR before trusting them.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;A few DEV posts I read along the way: &lt;a href="https://dev.to/getdropsheet/i-ran-ocr-in-the-browser-so-receipt-images-never-leave-the-device-22aj"&gt;I ran OCR in the browser so receipt images never leave the device&lt;/a&gt; has the same privacy instinct, &lt;a href="https://dev.to/2pizza/how-to-automate-invoice-reconciliation-4l7p"&gt;How to Automate Invoice Reconciliation&lt;/a&gt; covers matching from the business side, and &lt;a href="https://dev.to/tanu_priya/what-really-happens-when-you-make-an-online-payment-4kh8"&gt;What Really Happens When You Make an Online Payment?&lt;/a&gt; is a good explainer on where payment references come from.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Paper Trials: a lab notebook that sends you outside to throw paper airplanes</title>
      <dc:creator>TEJAS _</dc:creator>
      <pubDate>Sun, 11 Oct 2026 07:03:05 +0000</pubDate>
      <link>https://dev.to/jx777/paper-trials-a-lab-notebook-that-sends-you-outside-to-throw-paper-airplanes-2l9n</link>
      <guid>https://dev.to/jx777/paper-trials-a-lab-notebook-that-sends-you-outside-to-throw-paper-airplanes-2l9n</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Paper Trials is a lab notebook for paper airplanes. It doesn't fold anything for you and it doesn't simulate flight. It tells you which one thing to change about your plane, predicts how far it will fly, and then asks you to close the tab and go outside.&lt;/p&gt;

&lt;p&gt;The loop is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plan.&lt;/strong&gt; Pick a change, like "wing span 20 cm → 21 cm". Only one thing changes, so you'll know what made the difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predict.&lt;/strong&gt; The app predicts the median distance with a range and saves it. It can't be edited after that, so you can't quietly move the goalposts once you've seen the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fly.&lt;/strong&gt; Build it, go outside, throw it three times the same way, and measure where it lands. A tape is nice; counting steps works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log.&lt;/strong&gt; Come back, type in the distances, and add a note if something happened ("climbed, stalled, pulled left").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learn.&lt;/strong&gt; The model learns from every flight, and the notebook suggests the next change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The screen is only used at the start and the end. Everything in between happens on grass.&lt;/p&gt;

&lt;p&gt;I built it for people who like tinkering and wouldn't call it science. That covers kids, parents looking for an afternoon project, and teachers who want a reason to take a class outside. A paper plane is about the cheapest real experiment there is. It's also noisy enough to teach the habits that matter: change one variable at a time, write the prediction down first, repeat the test, and check whether your predictions were any good.&lt;/p&gt;

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

&lt;p&gt;Paper Trials runs on your own machine (both models run locally), so there's no hosted demo. Setup is in the &lt;a href="https://github.com/Surge77/paper-trials#running-it" rel="noopener noreferrer"&gt;README&lt;/a&gt;: build the frontend, &lt;code&gt;uv run papertrials&lt;/code&gt;, and optionally &lt;code&gt;ollama pull gemma3:4b&lt;/code&gt;. Here's the loop in screenshots, using the built-in sample notebook.&lt;/p&gt;

&lt;p&gt;The notebook suggests the next change. Each suggestion shows its predicted range, with your current best as a dashed line:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F961e4kfomfntumwi3few.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F961e4kfomfntumwi3few.png" alt="The notebook, with the next suggested change and its predicted range" width="799" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Test mode is the "go outside" screen. It shows exactly what to build, and the prediction is already locked in:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxsf2a9zwdr4ojsfzo786.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxsf2a9zwdr4ojsfzo786.png" alt="Test mode: what to build and the frozen prediction" width="799" height="632"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Lab plots every prediction against what the plane really did:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyy04gqtw30sa0kzzq1lw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyy04gqtw30sa0kzzq1lw.png" alt="The Lab: predicted ranges against real results, plane by plane" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Surge77" rel="noopener noreferrer"&gt;
        Surge77
      &lt;/a&gt; / &lt;a href="https://github.com/Surge77/paper-trials" rel="noopener noreferrer"&gt;
        paper-trials
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A lab notebook for paper airplanes: plan one change, fly it outside, and let TabPFN and Gemma learn from every throw.
    &lt;/h3&gt;
  &lt;/div&gt;
&lt;/div&gt;


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

&lt;p&gt;The backend is FastAPI and SQLite. The frontend is React with Recharts. Two open models do the interesting work, and plain code holds them together.&lt;/p&gt;

&lt;h3&gt;
  
  
  TabPFN predicts the distance
&lt;/h3&gt;

&lt;p&gt;The hard part of this project is the amount of data. After a weekend you might have 40 flights. Most models can't learn anything from 40 rows, and the ones that can tend to be very confident and very wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/PriorLabs/TabPFN" rel="noopener noreferrer"&gt;TabPFN&lt;/a&gt; is built for exactly this. It's a model pretrained on millions of synthetic tables, and it predicts from a small table of your own data in one forward pass, without any training. Each flight is a row: the plane's design, how it was thrown, and the wind. The target is the distance. I ask it for the 10th, 50th and 90th percentiles, and those become the predicted range.&lt;/p&gt;

&lt;p&gt;The range is the important bit. Paper planes are noisy, so "14.3 m" would be a lie, while "probably 12.9 to 15.5 m" is something you can actually check. The Lab keeps score of how often the real median lands inside the predicted range.&lt;/p&gt;

&lt;p&gt;Until there are 4 planes and 8 flights, TabPFN doesn't run. A nearest-neighbour guess fills in, its range widens the further a new plane is from anything you've flown, and the app labels it as a rough guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gotcha worth knowing:&lt;/strong&gt; newer TabPFN versions need a browser licence login before their weights download. My first version wrapped TabPFN in a try/except that fell back to the baseline, and it worked "fine". Then I noticed every single prediction in my sample data said &lt;code&gt;baseline&lt;/code&gt;. TabPFN had never run once. The v2 weights download without an account, so the app pins those. After the fix, the sample notebook's TabPFN predictions landed inside their range 6 times out of 8, against 2 out of 3 for the baseline, and they missed by 0.8 m on average instead of 1.5 m.&lt;/p&gt;

&lt;h3&gt;
  
  
  The next experiment comes from code, not a chatbot
&lt;/h3&gt;

&lt;p&gt;I didn't want a language model inventing plane designs. The recommender is under 200 lines of plain Python:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start from your best plane.&lt;/li&gt;
&lt;li&gt;Try every single-step change to one setting: a paper clip more or less, 1 cm wider or narrower, 5° sharper or blunter. Skip anything you've already built.&lt;/li&gt;
&lt;li&gt;Ask TabPFN about all of them at once and score each by predicted median, plus a bonus for a &lt;em&gt;wide&lt;/em&gt; range. A plane the model is unsure about teaches it the most, so it's worth a flight even when it isn't the favourite.&lt;/li&gt;
&lt;li&gt;If your notes keep mentioning a stall, changes known to fix stalls (a clip on the nose, less tail bend) get a bonus too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Gemma reads your notes, and gets checked
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma 3 4B&lt;/a&gt; runs locally through Ollama and has three small jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading flight notes.&lt;/strong&gt; "Went up, stalled and dropped" becomes the tag &lt;code&gt;stall&lt;/code&gt;, which the recommender can act on. Gemma returns JSON constrained to a fixed list of issues. When Ollama isn't running, keyword matching takes over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing a two-sentence lab note&lt;/strong&gt; after each experiment. This turned out to be the most interesting part, because a 4B model writing about numbers will get them wrong in creative ways. Real examples from my testing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The median distance &lt;strong&gt;increased&lt;/strong&gt; from 14.3 m to 14.1 m."&lt;/p&gt;

&lt;p&gt;"The flight notes indicated a stall and veer right, &lt;strong&gt;which caused&lt;/strong&gt; the median distance to fall below the predicted range."&lt;/p&gt;

&lt;p&gt;"No changes were made from the previous plane, &lt;strong&gt;resulting in a veer left&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every number in those is correct, so a "did it invent numbers?" check passes all three. So the code checks more than numbers. A note is rejected if it uses a number that isn't in the facts, says the distance went up when it went down, contradicts the inside/outside verdict, mentions a problem nobody reported, or claims a cause, because three throws can't tell you why something happened. A rejected note is replaced by a plain template. Each of those checks exists because Gemma did that exact thing at least once, and each has a test with the real sentence that triggered it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checking a photo.&lt;/strong&gt; Before you throw, you can show Gemma a photo and it estimates whether the wings look even and the nose looks sharp. It's labelled as an estimate with a confidence score, it's never used by the prediction model, and the photo isn't saved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honesty about the sample data
&lt;/h3&gt;

&lt;p&gt;The app ships with a sample notebook so you can look around before folding anything. Those flights are &lt;strong&gt;simulated&lt;/strong&gt;: a made-up formula plus noise. The predictions inside them are real, though. A script replays the experiments in order through the app's own recommender and models, so every sample prediction was made before its flights existed. The sample is labelled everywhere it appears and one button removes it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;It runs on a laptop with nothing to sign up for.&lt;/strong&gt; There are no API keys, no account and no bill. A teacher can install it on a school laptop and a class can use it all term for free. Gemma runs on my GTX 1650 through Ollama, and TabPFN runs on the CPU, which is plenty at this data size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The right tool for the job was an open one.&lt;/strong&gt; Asking a hosted chatbot "how far will this plane fly?" gets you a confident sentence. TabPFN gets you a calibrated range from 40 rows of your own data. Because the model sits inside my code, I could ask it for quantiles, batch every candidate into one call, and keep score of its accuracy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could see and fix what went wrong.&lt;/strong&gt; The licence fallback, the lab note that got the direction backwards: I found both because I could run the models locally as often as I liked and read every output. A rate-limited API makes you test less.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your data stays with you.&lt;/strong&gt; Your flight log, your notes and photos of your kid's paper planes stay on your own computer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's swappable.&lt;/strong&gt; The Gemma model is one environment variable. If a better small model comes out next month, change one line and the guard checks still apply.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN&lt;/strong&gt;: TabPFN makes every prediction, with quantile ranges, from a dataset that starts at zero rows, and drives the experiment recommender.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma 3 4B runs locally for flight-note tagging, grounded lab notes and photo checks, all with tested guards against the mistakes it actually made.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Built with PriorLabs-TabPFN.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>OfferProof: an offline job-scam checker for my fresher friends</title>
      <dc:creator>TEJAS _</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:15:45 +0000</pubDate>
      <link>https://dev.to/jx777/offerproof-an-offline-job-scam-checker-for-my-fresher-friends-1eh7</link>
      <guid>https://dev.to/jx777/offerproof-an-offline-job-scam-checker-for-my-fresher-friends-1eh7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;When I read "build for a friend", my first thought was that none of my friends had a problem I could fix in a weekend. Then I thought about what most of my batch is doing right now. We're all applying for our first jobs. And if you're a fresher looking for a job, you're exactly who fake job offers are aimed at.&lt;/p&gt;

&lt;p&gt;They look something like this. I wrote this one from public fraud advisories, so it isn't a real message, but every line in it is something those advisories warn about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;From:&lt;/strong&gt; &lt;a href="mailto:infosys.recruitment.cell@gmail.com"&gt;infosys.recruitment.cell@gmail.com&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Subject:&lt;/strong&gt; Offer Letter - Infosys Ltd&lt;/p&gt;

&lt;p&gt;Dear Candidate, congratulations! You are selected for System Engineer at Infosys, Pune.&lt;br&gt;
Your interview will be on Telegram, message our HR @infosys_hr_desk.&lt;br&gt;
Pay a refundable registration fee of Rs 1,999 within 2 hours to confirm your seat.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you've seen a few of these, you'd laugh at it. But if it's the first offer you've ever got, it says Infosys, it says congratulations, and it wants an answer in two hours. ₹1,999 to lock in a job at Infosys doesn't sound like much.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;OfferProof&lt;/strong&gt;. You paste the message in and it tells you which parts look like a scam, quotes the exact words it's worried about, and gives you a question to send back. Something like "can you email me from your official Infosys address?" That's an easy question for a real recruiter and a hard one for a scammer.&lt;/p&gt;

&lt;p&gt;It runs on your own laptop with Gemma 3 4B through Ollama. Offer letters have your phone number, address and PAN on them, and I didn't want anyone pasting that into some website.&lt;/p&gt;

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

&lt;p&gt;The first message is the one above: a Gmail address pretending to be Infosys, an interview on Telegram, and a "refundable" fee due in two hours. It flags all three, and the two-hour deadline too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcyaznge9e4ahpbe21ppx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcyaznge9e4ahpbe21ppx.gif" alt="OfferProof flagging a fake Infosys offer, with the quoted words behind each sign" width="760" height="585"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second is a normal Infosys email that happens to say "Infosys never charges a registration fee". A plain keyword search would flag it for the word "fee". OfferProof doesn't. It also says &lt;em&gt;Not verified&lt;/em&gt;, not &lt;em&gt;Genuine&lt;/em&gt;. No message can prove an offer is real, so it never claims that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzm22nx76jtztl7lip4dn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzm22nx76jtztl7lip4dn.gif" alt="OfferProof leaving a genuine Infosys email unflagged" width="760" height="585"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Gemma takes 15 to 30 seconds per message on my GTX 1650. I cut that wait out of the GIFs.)&lt;/em&gt;&lt;/p&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Surge77" rel="noopener noreferrer"&gt;
        Surge77
      &lt;/a&gt; / &lt;a href="https://github.com/Surge77/offerproof" rel="noopener noreferrer"&gt;
        offerproof
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Check a job offer for scam signs, locally, with an open-weight model.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;OfferProof&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Check a job offer for scam signs before you reply, pay, or share documents. It runs on your own computer
with an open-weight model, so your offer letter, phone number and PAN never leave it.&lt;/p&gt;
&lt;p&gt;Fake job offers mostly target people with little or no work experience. Most of them repeat a few tells
a fee to "confirm your seat", an interview held over Telegram, a Gmail address claiming to be a large
company. OfferProof looks for those tells and shows you the exact words that triggered each one.&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;$ offerproof check offer.txt
LIKELY SCAM: This message has clear signs of a job scam.

[severe] Personal email claiming to be Infosys
  "infosys.recruitment.cell@gmail.com"
  Large employers recruit from their own company domain, never from Gmail, Yahoo or Outlook.
  Ask them: Can you write to me from your official Infosys email address?

[severe] Asks you to pay money
  "Pay a refundable registration&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Surge77/offerproof" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


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

&lt;p&gt;I didn't want the model deciding "scam or not". A 4B model will give you a confident answer either way, and you can't tell why it said it. So Gemma only reads the message. Plain code makes the call.&lt;/p&gt;

&lt;p&gt;There are four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pattern rules in &lt;code&gt;signals.yaml&lt;/code&gt; for known scam wording. They skip negated sentences, which is why the "never charges a fee" email comes through clean.&lt;/li&gt;
&lt;li&gt;Domain checks in &lt;code&gt;companies.yaml&lt;/code&gt;. If a message claims to be from TCS but comes from a Gmail address, or from something like &lt;code&gt;tcs-careers.in&lt;/code&gt;, that gets flagged.&lt;/li&gt;
&lt;li&gt;Gemma answers a few narrow questions. Does it ask for money? An OTP? Documents? How is the interview being done? Every answer has to come with a quote from the message, and if that quote isn't actually in the message, the answer gets thrown away.&lt;/li&gt;
&lt;li&gt;A few lines of Python decide. Any severe sign means &lt;em&gt;likely scam&lt;/em&gt;. Anything else is &lt;em&gt;not verified&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Where it went wrong
&lt;/h3&gt;

&lt;p&gt;I wrote 20 test messages, 12 scams and 8 genuine ones, and ran everything locally.&lt;/p&gt;

&lt;p&gt;The first run caught all 12 scams. Looked great, until I noticed it had also flagged 3 of the 8 genuine messages.&lt;/p&gt;

&lt;p&gt;That confused me, because I was already checking that Gemma's quotes existed in the message. They did. The problem was what it used them for. It called a "quick call" a chat-only interview. It called a rejection email a chat-only interview too. And it decided a HackerRank assessment was a task scam. Every quote was real. None of them had anything to do with the claim.&lt;/p&gt;

&lt;p&gt;So a quote being in the message isn't enough, it has to be about the thing being claimed. Each signal now has a list of words its quote must contain. If Gemma says "interview only over chat", the quote has to mention Telegram, WhatsApp or chat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;quote_supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The quote must be about the claim, not just appear somewhere in the message.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_signals&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="n"&gt;signal_id&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also spelled out in the prompt the things it kept getting wrong. Phone and video calls aren't chat-only. Coding tests aren't task scams. If a message doesn't say how the interview happens, the answer is "not mentioned".&lt;/p&gt;

&lt;p&gt;After that:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Scams caught (of 12)&lt;/th&gt;
&lt;th&gt;Genuine wrongly flagged (of 8)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rules only&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model only&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Combined&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one scam it still misses says the "training material costs 999". I could add a rule for it, but it's one message I wrote myself, and tuning rules until my own test set passes wouldn't prove much.&lt;/p&gt;

&lt;p&gt;That's the real limitation right now: all 20 messages are mine. Next I want my friends to run it on messages they've actually received, and add those to the test set with names and numbers removed.&lt;/p&gt;

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

&lt;p&gt;For this project it's pretty practical.&lt;/p&gt;

&lt;p&gt;I'd only trust a tool with my offer letter if the letter never leaves my laptop. That works because Gemma is open and small enough to run on my GTX 1650.&lt;/p&gt;

&lt;p&gt;Scam scripts also change faster than one person can keep up with. The patterns are plain YAML, so if you've seen a scam OfferProof misses, adding it is a couple of lines and a test. &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; has the steps.&lt;/p&gt;

&lt;p&gt;And you don't have to take my word for the numbers. The test messages and the script are in the repo. Run them yourself, or point &lt;code&gt;OFFERPROOF_MODEL&lt;/code&gt; at a different model and see how it does.&lt;/p&gt;

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

&lt;p&gt;Best Use of Gemma. Gemma 3 4B does all the reading, locally through Ollama.&lt;/p&gt;




&lt;p&gt;If someone has sent you one of these and you're in India: don't pay, don't share OTPs or documents, and report it at &lt;a href="https://cybercrime.gov.in" rel="noopener noreferrer"&gt;cybercrime.gov.in&lt;/a&gt; or call 1930.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
  </channel>
</rss>
