<?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: mahuan2928</title>
    <description>The latest articles on DEV Community by mahuan2928 (@mahuan2928).</description>
    <link>https://dev.to/mahuan2928</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%2F632472%2F784c74a2-1c4d-4a2c-adf0-0bb37caf0f3f.jpeg</url>
      <title>DEV Community: mahuan2928</title>
      <link>https://dev.to/mahuan2928</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mahuan2928"/>
    <language>en</language>
    <item>
      <title>Don't Bolt LLM Calls Into Your App — Put Them Behind a Tiny API Instead</title>
      <dc:creator>mahuan2928</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:24:57 +0000</pubDate>
      <link>https://dev.to/mahuan2928/dont-bolt-llm-calls-into-your-app-put-them-behind-a-tiny-api-instead-8ga</link>
      <guid>https://dev.to/mahuan2928/dont-bolt-llm-calls-into-your-app-put-them-behind-a-tiny-api-instead-8ga</guid>
      <description>&lt;h2&gt;
  
  
  The problem: the five-line feature that metastasizes
&lt;/h2&gt;

&lt;p&gt;Every team adds their first LLM feature the same way. Someone writes five lines inside the existing codebase:&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="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. Then product asks for a second feature — chat. Then classification for the support inbox. Six months later, OpenAI calls are scattered across four modules, the API key lives in three config files, nobody knows which endpoint is burning tokens, and swapping models means a search-and-replace across the whole repo. The LLM stopped being a feature and became infrastructure — except nobody built it like infrastructure.&lt;/p&gt;

&lt;p&gt;There's a boring pattern that prevents this: &lt;strong&gt;put LLM calls behind a tiny HTTP service of your own.&lt;/strong&gt; Your app talks to one small FastAPI sidecar; the sidecar talks to the provider. Keys, prompts, and provider quirks live in exactly one place.&lt;/p&gt;

&lt;p&gt;I built a free demo of this pattern — one endpoint, about 150 lines — so you can see the shape before committing to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo: one feature, done cleanly
&lt;/h2&gt;

&lt;p&gt;The demo service has exactly two endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /health&lt;/code&gt; — no auth, just liveness&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /features/summarize&lt;/code&gt; — takes &lt;code&gt;{text, max_sentences}&lt;/code&gt;, returns a summary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is a single &lt;code&gt;app.py&lt;/code&gt;. Here's the interesting part — the auth dependency:&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;require_api_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x_api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SERVICE_API_KEY&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SERVICE_API_KEY is not set on the server.&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;x_api_key&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid or missing X-API-Key.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your backend sends a shared &lt;code&gt;X-API-Key&lt;/code&gt; header; browsers and mobile apps never see it, because they talk to &lt;em&gt;your&lt;/em&gt; backend, not to this service. One key, one check, every feature endpoint protected by adding &lt;code&gt;dependencies=[Depends(require_api_key)]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And here's the detail most demos skip — what happens when the OpenAI key is missing:&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="n"&gt;openai_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPENAI_API_KEY&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;openai_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;detail&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="s"&gt;OPENAI_API_KEY is not set. Copy .env.example &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to .env and add your OpenAI API key.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a stack trace. Not a service that crashes on boot at 2 a.m. A clean &lt;code&gt;503&lt;/code&gt; that tells the next developer exactly what to fix. Small thing, but it's the difference between a template and a toy.&lt;/p&gt;

&lt;p&gt;The summarize endpoint itself is deliberately unremarkable:&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="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/features/summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dependencies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;require_api_key&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SummarizeRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# ... missing-key check above ...
&lt;/span&gt;    &lt;span class="n"&gt;completion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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="s"&gt;system&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="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarize in at most &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
             &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_sentences&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; sentence(s). Keep key facts and numbers.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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="s"&gt;user&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="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env   &lt;span class="c"&gt;# set SERVICE_API_KEY + OPENAI_API_KEY&lt;/span&gt;
uvicorn app:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8000

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8000/features/summarize &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-API-Key: YOUR_KEY'&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"text": "Long ticket text...", "max_sentences": 2}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the pattern. Your Rails, Django, Laravel, or Node backend doesn't care that Python and OpenAI are on the other side of the HTTP call — and that's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the demo stops (on purpose)
&lt;/h2&gt;

&lt;p&gt;The demo is honest about being a demo. The moment this serves real users, three production concerns show up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Who spent what?&lt;/strong&gt; A summarize call costs fractions of a cent — a runaway loop or one enthusiastic client costs real money. Production needs per-client token budgets, checked &lt;em&gt;before&lt;/em&gt; the provider call: estimate the request, and if the client is over their daily cap, return &lt;code&gt;402&lt;/code&gt; without spending anything. Settling with the provider's real usage numbers afterwards keeps the accounting honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What happens when the provider hiccups?&lt;/strong&gt; OpenAI returns 429s and 5xx like everyone else. Without retries with backoff and a sane timeout, your users see raw failures for what was a two-second blip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Can you prove what happened?&lt;/strong&gt; When finance asks "why did the bill triple in March?", you need one JSON log line per call — endpoint, client, input/output tokens, latency — not a forensic expedition through application logs.&lt;/p&gt;

&lt;p&gt;Add streaming chat (SSE) and a classify endpoint, and you've described the full version of this service — same request/response shape, with budgets, rate limits, retries, logging, and a provider abstraction so "switch to Claude" is a config change, not a refactor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The demo is free and MIT-licensed — clone it, run the three smoke tests (&lt;code&gt;/health&lt;/code&gt; → 200, no key → 401, no OpenAI key → clean 503), and wire summarize into your app this afternoon:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/mahuan2928/llm-feature-demo" rel="noopener noreferrer"&gt;https://github.com/mahuan2928/llm-feature-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want the production version — &lt;code&gt;/features/chat&lt;/code&gt; with SSE streaming, &lt;code&gt;/features/classify&lt;/code&gt;, per-client daily token budgets with HTTP 402 enforcement, rate limiting, retries with backoff, and JSON call logging — it's here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full version ($49):&lt;/strong&gt; &lt;a href="https://mahuan220.gumroad.com/l/gwwrrn" rel="noopener noreferrer"&gt;https://mahuan220.gumroad.com/l/gwwrrn&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start with the demo. You'll know within one integration whether the pattern fits — and if it does, the upgrade path is a drop-in, not a rewrite.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by Marty Ma — Senior AI &amp;amp; Data Engineer (Tokyo), 11+ years building production systems for enterprise clients.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>llm</category>
      <category>api</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>Stop Rewriting Your Web Scraper for Every New Site</title>
      <dc:creator>mahuan2928</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:23:55 +0000</pubDate>
      <link>https://dev.to/mahuan2928/stop-rewriting-your-web-scraper-for-every-new-site-5h00</link>
      <guid>https://dev.to/mahuan2928/stop-rewriting-your-web-scraper-for-every-new-site-5h00</guid>
      <description>&lt;p&gt;Every web scraper I've ever written started the same way: a quick script for one site, hardcoded selectors, done in an afternoon. Then someone asks for "the same thing, but for this other site" — and you copy the file, change the URL, re-hunt every selector, rediscover the pagination quirks, and ship a second script that shares 90% of its DNA with the first. By the fourth site you're maintaining a small zoo of near-identical programs, each of which breaks independently whenever a site redesigns.&lt;/p&gt;

&lt;p&gt;This post builds the honest version of that first script — small, readable, actually working — and then shows where it hits its ceiling, and the config-driven pattern I now use instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo: one file, one site, twenty books
&lt;/h2&gt;

&lt;p&gt;The target is &lt;a href="https://books.toscrape.com" rel="noopener noreferrer"&gt;books.toscrape.com&lt;/a&gt;, a practice site built for exactly this. Goal: every book on the first catalogue page — title, price, rating, availability — into a CSV. The whole thing is one file, &lt;code&gt;scrape.py&lt;/code&gt;, using only &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;BeautifulSoup&lt;/code&gt;:&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="n"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://books.toscrape.com/catalogue/page-1.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&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="s"&gt;Mozilla/5.0 ... web-scraper-demo/1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# don't let requests guess — see below
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in those few lines matter more than the rest of the script combined. First, send a real User-Agent and keep your request rate polite; scraping that hammers a server is how IPs get banned and side projects get legal letters. Second, the encoding line: on my first run, every price came out as &lt;code&gt;Â£51.77&lt;/code&gt; instead of &lt;code&gt;£51.77&lt;/code&gt;, because &lt;code&gt;requests&lt;/code&gt; trusted the response headers over the page's actual UTF-8 content. One explicit line fixed all twenty rows. Small bug, but it's the kind that silently poisons a dataset if you're not eyeballing your output.&lt;/p&gt;

&lt;p&gt;Parsing is one function. Each book is an &lt;code&gt;article.product_pod&lt;/code&gt; card, and everything we want hangs off it:&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;for&lt;/span&gt; &lt;span class="n"&gt;pod&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article.product_pod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h3 a&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="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p.price_color&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rating_el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p.star-rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rating&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="n"&gt;rating_el&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;class&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;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RATING_WORDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;availability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p.availability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rating is a nice example of real-world markup: it isn't text at all, it's a CSS class (&lt;code&gt;class="star-rating Three"&lt;/code&gt;), so you extract it from the class list rather than the element's contents. The availability text comes with stray whitespace and a nested icon element, so it gets normalized with a split-and-join. Then &lt;code&gt;csv.DictWriter&lt;/code&gt; writes the rows out, and you have &lt;code&gt;books.csv&lt;/code&gt; with 20 clean records. Total: about 60 lines. It works. Ship it?&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the one-file scraper breaks
&lt;/h2&gt;

&lt;p&gt;It breaks at the second site, and the failures are structural, not incidental:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Selectors live in code.&lt;/strong&gt; A new site means editing Python, re-testing, and redeploying — for what is really just &lt;em&gt;data&lt;/em&gt; about the site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination is a snowflake.&lt;/strong&gt; This demo deliberately stops at page 1. Real sites paginate with next-links, page-number URLs, or infinite scroll, and each style needs different driving logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not everything is static HTML.&lt;/strong&gt; The moment a site renders with JavaScript, &lt;code&gt;requests&lt;/code&gt; gets an empty shell and your selectors match nothing — silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody counts the bad rows.&lt;/strong&gt; Real pages have missing prices, duplicate listings, layout variants. A quick script mixes them into your CSV and you find out downstream, in a spreadsheet, at the worst time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are hard problems individually. Together they mean the "quick script" quietly becomes the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: the site is data, not code
&lt;/h2&gt;

&lt;p&gt;The pattern I use in production flips the script inside out. Everything site-specific — start URLs, the item container, per-field selectors and types, pagination style, rate limits, dedup keys — moves into a YAML config. The engine stays fixed. A new site looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;books&lt;/span&gt;
  &lt;span class="na"&gt;start_urls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://books.toscrape.com/catalogue/page-1.html"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;rate_limit_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.0&lt;/span&gt;
&lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article.product_pod"&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;css&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;attr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;css&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p.price_color"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;float&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;pagination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;next_link&lt;/span&gt;
  &lt;span class="na"&gt;next_selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;li.next&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a"&lt;/span&gt;
&lt;span class="na"&gt;dedup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;key_fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same target, same fields — but now adding a site is writing twenty lines of YAML instead of forking a program. The engine adds the things the demo lacks: all three pagination styles, a Playwright mode for JS-rendered pages, type coercion and required-field validation, deduplication with a count of dropped rows (so bad data is a number in your log, not a surprise in your spreadsheet), Excel output, and retries with backoff so it can run unattended from cron.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, then skip the rewrite
&lt;/h2&gt;

&lt;p&gt;The demo from this post is free and MIT-licensed — clone it, run it, take it apart:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/mahuan2928/web-scraper-demo" rel="noopener noreferrer"&gt;https://github.com/mahuan2928/web-scraper-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And if you're at the "second site" stage — or you can see it coming — the full config-driven engine is available here, with example configs (including this same book site, all 50 pages of it), the Playwright mode, Sheets export, and the validation/dedup pipeline described above:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Config-Driven Web Scraper Engine ($39):&lt;/strong&gt; &lt;a href="https://mahuan220.gumroad.com/l/zknkka" rel="noopener noreferrer"&gt;https://mahuan220.gumroad.com/l/zknkka&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Build the demo version once to learn the mechanics. Just don't build it five times.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— Marty Ma, Senior AI &amp;amp; Data Engineer (Tokyo)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Stop Copy-Pasting Invoices: Extract PDF Data to CSV with 100 Lines of Python</title>
      <dc:creator>mahuan2928</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:22:47 +0000</pubDate>
      <link>https://dev.to/mahuan2928/stop-copy-pasting-invoices-extract-pdf-data-to-csv-with-100-lines-of-python-4o88</link>
      <guid>https://dev.to/mahuan2928/stop-copy-pasting-invoices-extract-pdf-data-to-csv-with-100-lines-of-python-4o88</guid>
      <description>&lt;h1&gt;
  
  
  Stop Copy-Pasting Invoices: Extract PDF Data to CSV with 100 Lines of Python
&lt;/h1&gt;

&lt;p&gt;Every month, somebody on every finance team does the same soul-crushing job: open an invoice PDF, copy the invoice number into a spreadsheet, copy the date, copy the total, repeat forty times. It's slow, it's boring, and it's exactly the kind of work where humans make quiet mistakes — a transposed digit in a total doesn't announce itself until the books don't balance.&lt;/p&gt;

&lt;p&gt;The good news: if your invoices are &lt;em&gt;text-based&lt;/em&gt; PDFs (the kind exported by accounting software, not scanned paper), this is a solved problem with embarrassingly little code. In this post we'll build a batch extractor in Python: point it at a folder of PDFs, get a CSV out. Full working demo, MIT licensed, is on GitHub — link at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Get the text out of the PDF
&lt;/h2&gt;

&lt;p&gt;We'll use &lt;code&gt;pdfplumber&lt;/code&gt;, which wraps pdfminer and gives you clean page text:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pdfplumber&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pdf_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pdfplumber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole "PDF parsing engine." For born-digital invoices, the text layer is already there — we just need to mine it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Extract fields with anchored regexes
&lt;/h2&gt;

&lt;p&gt;Invoice layouts vary, but the labels are remarkably stable. Three patterns cover our three fields:&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="n"&gt;FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;compile&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;Invoice\s*(?:No\.?|Number|#)\s*[:#]?\s*([A-Za-z0-9\-]+)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;compile&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;\bDate\s*[:\-]?\s*(\d{4}-\d{2}-\d{2})&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;compile&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*Total\s*:\s*\$?\s*([\d,]+\.\d{2})&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&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="n"&gt;MULTILINE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail matters here: the &lt;code&gt;total_amount&lt;/code&gt; pattern is anchored to the &lt;strong&gt;start of a line&lt;/strong&gt;. Without that anchor, &lt;code&gt;Subtotal: $1,000.00&lt;/code&gt; happily matches a search for &lt;code&gt;Total:&lt;/code&gt; — and your CSV fills up with subtotals wearing a fake moustache. Ask me how I know.&lt;/p&gt;

&lt;p&gt;Amounts get a light normalization (&lt;code&gt;1,100.00&lt;/code&gt; → &lt;code&gt;1100.00&lt;/code&gt;) so the CSV is spreadsheet- and diff-friendly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Never drop a row silently
&lt;/h2&gt;

&lt;p&gt;Here's the design decision I'd defend in a code review: when a field isn't found, we &lt;strong&gt;still write the row&lt;/strong&gt;, leave the field empty, and name the gap in a &lt;code&gt;status&lt;/code&gt; column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;filename,invoice_number,invoice_date,total_amount,status
partial.pdf,INV-2026-099,2026-03-01,,missing: total_amount
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A batch tool that quietly skips files is a batch tool that creates reconciliation mysteries. Forty invoices in, thirty-nine rows out, and no clue which one vanished? No thanks. The status column turns "the numbers don't add up" into a ten-second lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Batch it and test it
&lt;/h2&gt;

&lt;p&gt;The main loop is unglamorous: glob &lt;code&gt;*.pdf&lt;/code&gt;, extract each, write rows with &lt;code&gt;csv.DictWriter&lt;/code&gt;, print a per-file status. The part most tutorials skip is the &lt;em&gt;test&lt;/em&gt;: the demo repo generates two synthetic invoices with ReportLab, ships an &lt;code&gt;expected_output.csv&lt;/code&gt; with the known-correct answers, and the smoke test is literally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python make_sample.py
python extract.py &lt;span class="nt"&gt;--input&lt;/span&gt; ./samples &lt;span class="nt"&gt;--out&lt;/span&gt; results.csv
diff results.csv expected_output.csv   &lt;span class="c"&gt;# silence means success&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Synthetic samples matter more than they look. You can't put real client invoices in a public repo, and "it worked on my machine once" isn't a test. Generate your fixtures, check them in, and let the diff be the judge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the demo stops — and production starts
&lt;/h2&gt;

&lt;p&gt;This demo extracts three hardcoded fields from well-behaved PDFs. Real invoice work is messier, and three problems show up immediately:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every vendor has a different layout.&lt;/strong&gt; Hardcoded regexes become a maintenance hobby. You want the fields — names, patterns, types — defined in a config file, so a new vendor is a new YAML block, not a code change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extraction without validation is a liability.&lt;/strong&gt; Regex found &lt;em&gt;a&lt;/em&gt; number after &lt;code&gt;Total:&lt;/code&gt; — but is it right? The check that catches real money errors is cross-field arithmetic: does &lt;code&gt;total == subtotal + tax&lt;/code&gt;? I once generated a test invoice with a deliberately wrong total (500 + 50 ≠ 560) specifically to watch a validator flag it. That's the feature that pays for itself the first time it catches a fat-fingered invoice before payment, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Someone has to review the weird ones.&lt;/strong&gt; In production you want a validation report and a "needs human review" list — the 5% of files that deserve eyes — instead of a human re-checking 100% of them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Add per-field confidence scores, Excel output, and an OCR path for scanned PDFs, and the demo grows into the tool I actually ship to clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / get the full version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free demo (MIT):&lt;/strong&gt; &lt;a href="https://github.com/mahuan2928/pdf-extraction-demo" rel="noopener noreferrer"&gt;https://github.com/mahuan2928/pdf-extraction-demo&lt;/a&gt; — samples, expected output, and the whole ~100-line extractor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full version ($39):&lt;/strong&gt; &lt;a href="https://mahuan220.gumroad.com/l/yilvrj" rel="noopener noreferrer"&gt;PDF &amp;amp; Invoice Data Extraction Pipeline&lt;/a&gt; — fields defined in &lt;code&gt;fields.yaml&lt;/code&gt; (any fields, not just three), validation including cross-field totals checks, a validation report plus human-review list, Excel outputs, per-field confidence scores, and an OCR path for scanned PDFs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you adapt the demo to your own invoice layouts, I'd love to hear which pattern broke first — vendor creativity in invoice design is truly boundless.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— Marty Ma, Senior AI &amp;amp; Data Engineer (Tokyo)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>pdf</category>
      <category>automation</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Build a RAG Chatbot That Admits When It Doesn't Know (Pure Python, No Dependencies)</title>
      <dc:creator>mahuan2928</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:21:18 +0000</pubDate>
      <link>https://dev.to/mahuan2928/build-a-rag-chatbot-that-admits-when-it-doesnt-know-pure-python-no-dependencies-2ec3</link>
      <guid>https://dev.to/mahuan2928/build-a-rag-chatbot-that-admits-when-it-doesnt-know-pure-python-no-dependencies-2ec3</guid>
      <description>&lt;h2&gt;
  
  
  The problem: chatbots that guess
&lt;/h2&gt;

&lt;p&gt;Every team wants a chatbot that answers questions from their own documents — support FAQs, internal policies, product manuals. The naive approach is to paste documents into a prompt or fine-tune a model, and the failure mode is always the same: when the documents don't contain the answer, the model invents one. A confident, wrong refund policy is worse than no answer at all.&lt;/p&gt;

&lt;p&gt;Retrieval-augmented generation (RAG) fixes the retrieval half of this: you search your documents first, and only answer from what you found. But retrieval alone isn't enough. You also need a &lt;strong&gt;gate&lt;/strong&gt; — a relevance threshold below which the bot refuses to answer — and &lt;strong&gt;citations&lt;/strong&gt;, so a human can verify every claim.&lt;/p&gt;

&lt;p&gt;In this tutorial we'll build a minimal version of that pattern in pure Python. No LangChain, no vector database, no dependencies at all: just the standard library. It runs completely offline using TF-IDF, and if you set an &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; it transparently switches to real embeddings and a chat model. The full code is about 300 lines across three files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Chunk the documents
&lt;/h2&gt;

&lt;p&gt;Embeddings work on passages, not whole files, so ingestion starts by splitting each document into chunks. A simple paragraph-aware splitter is good enough for a demo: accumulate paragraphs until you approach the size limit, and window-split any single paragraph that's too long.&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;chunk_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;paras&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&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;split&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;\n\s*\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;para&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paras&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;para&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;chunk_size&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;buf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;chunks&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="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;para&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;chunks&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="n"&gt;para&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;para&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;para&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;para&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;chunks&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="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;para&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunks&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="n"&gt;buf&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;chunks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each chunk keeps its source filename and position, which is what later lets us print citations like &lt;code&gt;[Source: product-faq.txt, chunk 2]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Build the index (TF-IDF offline, embeddings online)
&lt;/h2&gt;

&lt;p&gt;Offline, we represent every chunk as a TF-IDF vector: term counts weighted by inverse document frequency, L2-normalised so cosine similarity is a plain dot product. It's fifty lines with &lt;code&gt;collections.Counter&lt;/code&gt; and &lt;code&gt;math.log&lt;/code&gt; — and for small, keyword-friendly document sets it works surprisingly well.&lt;/p&gt;

&lt;p&gt;When an API key is available, we embed the chunks with OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; instead. Notice we don't even need the SDK; a JSON POST with &lt;code&gt;urllib&lt;/code&gt; does the job:&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;openai_embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.openai.com/v1/embeddings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&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="s"&gt;Content-Type&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="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way, the result is one boring &lt;code&gt;index.json&lt;/code&gt;: the chunks, their vectors, the mode, and a relevance threshold (0.10 for TF-IDF — its cosine scores run lower than embedding similarities, so each mode gets its own threshold).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The honest gate
&lt;/h2&gt;

&lt;p&gt;This is the most important fifteen lines in the project. At query time we embed the question the same way we embedded the chunks, rank by cosine similarity — and then check the best score &lt;em&gt;before&lt;/em&gt; generating anything:&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="n"&gt;scored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;cosine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vec&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;scored&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# "I don't know -- that information isn't
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt;            &lt;span class="c1"&gt;#  in the documents provided."
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If nothing retrieved clears the bar, the program prints the fallback and stops. The language model is never called, so it &lt;em&gt;cannot&lt;/em&gt; hallucinate an answer the documents don't support. When the gate passes, the chat model receives only the retrieved chunks and is instructed to answer strictly from them, citing every claim. In offline mode there's no model at all: the top cited excerpts are printed as the answer, which is less fluent but arguably even more transparent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python ingest.py
python ask.py &lt;span class="s2"&gt;"How long is the warranty for the Nimbus kettle?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo ships with two small sample documents — a fictional product FAQ and a fictional company policy — so it works out of the box. The smoke test, run fully offline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Retrieved sources:
  [0.383] [Source: product-faq.txt, chunk 1]
  [0.353] [Source: product-faq.txt, chunk 2]

Answer (top excerpts from your documents):
How long is the warranty? Every Nimbus Smart Kettle comes with a
2-year warranty that covers manufacturing defects... [Source: product-faq.txt, chunk 2]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the question the documents can't answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python ask.py &lt;span class="s2"&gt;"What is the current stock price of Tesla?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Best match score: 0.000 (threshold: 0.1)
I don't know -- that information isn't in the documents provided.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly the behaviour we want: an answer with receipts when the documents support one, and a refusal when they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;p&gt;This is a teaching demo, not a product. TF-IDF matches keywords, not meaning, so a paraphrased question ("how long does the guarantee last?") can miss a chunk that says "warranty". A JSON file is a fine index for hundreds of chunks and a poor one for millions. There's no PDF parsing, no web ingestion, no conversation memory, no server — it's a CLI that answers one question at a time. Thresholds also need tuning per document set; 0.10 worked for the samples, not for every corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;The demo is MIT-licensed and lives at &lt;a href="https://github.com/mahuan2928/rag-chatbot-demo" rel="noopener noreferrer"&gt;https://github.com/mahuan2928/rag-chatbot-demo&lt;/a&gt; — fork it, drop your own &lt;code&gt;.txt&lt;/code&gt; and &lt;code&gt;.md&lt;/code&gt; files into &lt;code&gt;docs/&lt;/code&gt;, and break it.&lt;/p&gt;

&lt;p&gt;If you need the production shape of this pattern — PDF and web-page ingestion, a FastAPI server with an embeddable chat widget, Chroma/pgvector/Pinecone instead of a JSON file, session memory, per-IP rate limiting, and config-driven setup — that's the full &lt;strong&gt;RAG Chatbot Starter Kit&lt;/strong&gt; I sell here: &lt;a href="https://mahuan220.gumroad.com/l/ohfaru" rel="noopener noreferrer"&gt;https://mahuan220.gumroad.com/l/ohfaru&lt;/a&gt; ($49, one commercial project per license). It's the same architecture, hardened for client work.&lt;/p&gt;

&lt;p&gt;Either way: gate your retrieval, cite your sources, and let your chatbot say "I don't know."&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>rag</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
