Every scanner and phone scan app names files the same useless way: scan_0001.pdf, IMG_20260314_101522.jpg, Document (14).pdf. After a year, a small office has a folder of 1,500 files like that. Finding last year’s land tax (PBB) receipt, a supplier contract, or the warranty card for the air conditioner means opening files one by one.
Renaming them by hand means opening each file, finding the date and who it’s from, typing a name and dragging it into a folder. That takes 45 to 90 seconds per document. Time yourself on 20 files, because that number decides whether this project is worth it for you. At 1,000 documents and one minute each, that’s about 17 hours of work nobody wants to do.
In this guide you’ll build a small Python tool that:
- reads the first two pages of each PDF or image in an
inbox/folder - asks Google’s Gemini API for the date, issuer, document type and a short title, returned as structured JSON
- builds a clean filename and folder path like
archive/utilities/2026/2026-03-14_pln_bill_electricity-bill-march.pdf - writes everything to a plan file (CSV) first, so nothing moves until you’ve checked it
- moves the files only after you approve the plan, and keeps an undo log
The AI cost is roughly $0.001–0.002 per document, so about $1–2 for 1,000 files. Build time is about an hour.
What you need
| Item | Cost | Notes |
|---|---|---|
| Python 3.10+ | $0 | Windows, macOS or Linux |
| Gemini API key | $0 to start | From aistudio.google.com, “Get API key” |
google-genai, pydantic, pypdf, pillow | $0 | pip install |
| A spreadsheet app | $0 | Excel, LibreOffice Calc or Google Sheets, used to check the plan |
ocrmypdf (optional) | $0 | Adds a searchable text layer to scans, see Step 6 |
| Scans | $0 | A flatbed or document scanner, or a phone (Google Drive’s scan button on Android, the Notes or Files app on iPhone) |
Pricing as of October 2026 (check ai.google.dev/pricing before you rely on it, because Google changes model names and prices often):
- Gemini 3.5 Flash-Lite (
gemini-3.5-flash-lite, used in this guide), paid tier: $0.30 per 1M input tokens (text/image), $2.50 per 1M output tokens, thinking tokens included - Gemini 3.8 Flash (
gemini-3.8-flash), paid tier: $0.75 input / $3.75 output per 1M tokens until December 31, 2026, then $1.50 / $7.50 from January 1, 2027. It’s worth trying on faded or handwritten scans, but input costs 2.5× as much. - Free tier: both have a rate-limited free tier, which is enough for testing on your own documents. Read the privacy note in the Reality check before you put anyone else’s papers through it.
Why not Gemini 2.5 Flash? It’s cheaper on paper, but Google now limits the 2.5 models to projects that actively used them before, and recommends 3.5 Flash-Lite or 3.8 Flash for new projects. A new API key will most likely get an error on gemini-2.5-flash. If your project still has access, you can use it by changing the model name and the thinking setting (see Reality check).
How the cost works out
On Gemini 3 models, Google’s media-resolution docs count a PDF page as 560 tokens at the default setting, and an image as up to 1,120 tokens. The script sends only the first two pages, because the date, issuer and type of almost any document are on page one. A 40-page contract costs the same as a one-page receipt.
Per document on Gemini 3.5 Flash-Lite, using generous numbers:
- Input: 2 pages (~1,120 tokens) + prompt (~400 tokens), so budget ~1,600 tokens × $0.30/1M = $0.0005
- Output: ~150 tokens of JSON plus a little thinking, so budget ~300 tokens × $2.50/1M = $0.00075
- Total: about $0.001–0.002 per document
So 1,000 documents cost roughly $1–2, and a few re-runs while you tune the prompt add a few cents. Gemini 3 models always “think” a bit, and thinking tokens are billed as output. The script sets thinking_level="minimal", the lowest level 3.5 Flash-Lite supports, because you don’t need deep reasoning to read a date off a letterhead.
Step 1: Set up the project
mkdir docfiler && cd docfiler
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install google-genai pydantic pypdf pillow
mkdir inbox archive
export GEMINI_API_KEY="paste-your-key-here" # Windows PowerShell: $env:GEMINI_API_KEY="..."
The folder layout:
docfiler/
├── inbox/ # copy the messy scans here (work on a COPY, see Reality check)
├── archive/ # renamed files end up here, sorted by category/year
├── plan.csv # what the script wants to do; you edit/approve this
├── undo.csv # written by apply.py, lets you reverse every move
├── plan.py
├── apply.py
└── undo.py
Step 2: Decide your categories and document types
This is the part worth 10 minutes of thought. The model can only file into categories you define, and fixed lists keep the folder tree predictable. Here’s a starting set for a small business or a household. Edit it to match how you actually look for papers:
- Categories (top-level folders):
tax,banking,utilities,insurance,property,vehicle,suppliers,customers,employees,legal,medical,personal,other - Document types:
invoice,receipt,bill,statement,contract,letter,certificate,tax-form,policy,warranty,id-document,report,other
Step 3: The planning script
Save this as plan.py. It never moves or changes a file. It only reads them and writes plan.csv.
import csv, os, re, sys, time, unicodedata
from datetime import date, datetime
from io import BytesIO
from pathlib import Path
from google import genai
from google.genai import types
from PIL import Image, ImageOps
from pydantic import BaseModel
from pypdf import PdfReader, PdfWriter
MODEL = os.environ.get("FILER_MODEL", "gemini-3.5-flash-lite")
THINKING = os.environ.get("FILER_THINKING", "minimal") # 3.8 Flash: "low"
CATEGORIES = ["tax", "banking", "utilities", "insurance", "property", "vehicle",
"suppliers", "customers", "employees", "legal", "medical",
"personal", "other"]
DOC_TYPES = ["invoice", "receipt", "bill", "statement", "contract", "letter",
"certificate", "tax-form", "policy", "warranty", "id-document",
"report", "other"]
INBOX, ARCHIVE = Path("inbox"), Path("archive")
EXTS = {".pdf", ".jpg", ".jpeg", ".png", ".webp"}
class DocInfo(BaseModel):
doc_date: str # YYYY-MM-DD, or "unknown"
issuer: str # who sent/issued it, short
doc_type: str
category: str
short_title: str # 2-5 words
confidence: str # high, medium, low
PROMPT = f"""You are filing a scanned document. Look at the pages and return:
- doc_date: the date the document was issued, as YYYY-MM-DD. Not a due date or
a print date. If there is no clear issue date, return "unknown". Do not guess.
- issuer: the company, office or person that issued it, short (e.g. "PLN",
"BCA", "Kantor Pajak Pratama Bogor", "Toko Sumber Jaya").
- doc_type: one of {", ".join(DOC_TYPES)}.
- category: one of {", ".join(CATEGORIES)}.
- short_title: 2 to 5 words saying what it is, in English, e.g.
"electricity bill march", "car insurance renewal", "office lease".
- confidence: "high" only if date, issuer and type are all clearly printed.
Never invent a date, number or name you cannot read."""
client = genai.Client() # reads GEMINI_API_KEY
def first_pages_pdf(path: Path, n: int = 2) -> bytes:
reader = PdfReader(path)
if reader.is_encrypted and not reader.decrypt(""): # "" opens owner-only locks
raise ValueError("password-protected PDF")
writer = PdfWriter()
for page in reader.pages[:n]:
writer.add_page(page)
buf = BytesIO()
writer.write(buf)
return buf.getvalue()
def image_jpeg(path: Path) -> bytes:
img = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
img.thumbnail((1600, 1600)) # caps tokens and upload size
buf = BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
def read_doc(path: Path) -> DocInfo:
if path.suffix.lower() == ".pdf":
part = types.Part.from_bytes(data=first_pages_pdf(path), mime_type="application/pdf")
else:
part = types.Part.from_bytes(data=image_jpeg(path), mime_type="image/jpeg")
resp = client.models.generate_content(
model=MODEL,
contents=[part, PROMPT],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=DocInfo,
temperature=0,
thinking_config=types.ThinkingConfig(thinking_level=THINKING),
),
)
if resp.parsed is None:
raise ValueError("response did not match schema")
return resp.parsed
def slug(text: str, max_len: int = 40) -> str:
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-")
return text[:max_len].rstrip("-") or "unknown"
def check(d: DocInfo) -> list[str]:
issues = []
if d.doc_date == "unknown":
issues.append("no date")
else:
try:
parsed = datetime.strptime(d.doc_date, "%Y-%m-%d").date()
if parsed > date.today() or parsed.year < 1980:
issues.append(f"suspicious date {d.doc_date}")
except ValueError:
issues.append(f"bad date format {d.doc_date}")
if d.category not in CATEGORIES:
issues.append(f"unknown category {d.category}")
if d.doc_type not in DOC_TYPES:
issues.append(f"unknown type {d.doc_type}")
if not d.issuer.strip():
issues.append("no issuer")
if d.confidence != "high":
issues.append(f"confidence {d.confidence}")
return issues
def dest_for(d: DocInfo, src: Path, taken: set[str]) -> Path:
ok_date = re.fullmatch(r"\d{4}-\d{2}-\d{2}", d.doc_date)
year = d.doc_date[:4] if ok_date else "undated"
category = d.category if d.category in CATEGORIES else "other"
name = "_".join([d.doc_date if ok_date else "undated", slug(d.issuer, 25),
slug(d.doc_type, 15), slug(d.short_title)])
ext = ".jpg" if src.suffix.lower() == ".jpeg" else src.suffix.lower()
dest, n = ARCHIVE / category / year / f"{name}{ext}", 2
while str(dest) in taken or dest.exists(): # never overwrite anything
dest = ARCHIVE / category / year / f"{name}-{n}{ext}"
n += 1
taken.add(str(dest))
return dest
def main():
files = sorted(p for p in INBOX.rglob("*") if p.suffix.lower() in EXTS)
taken, rows = set(), []
for i, src in enumerate(files, 1):
try:
d = read_doc(src)
except Exception as e: # quota, network, bad file
print(f"[{i}/{len(files)}] ERROR {src.name}: {e}", file=sys.stderr)
rows.append({"action": "skip", "src": str(src), "dest": "",
"issues": f"error: {e}", "date": "", "issuer": "",
"type": "", "category": "", "title": "", "confidence": ""})
continue
issues = check(d)
dest = dest_for(d, src, taken)
rows.append({"action": "review" if issues else "move", "src": str(src),
"dest": str(dest), "issues": "; ".join(issues),
"date": d.doc_date, "issuer": d.issuer, "type": d.doc_type,
"category": d.category, "title": d.short_title,
"confidence": d.confidence})
print(f"[{i}/{len(files)}] {rows[-1]['action']:6} {src.name} -> {dest}")
time.sleep(float(os.environ.get("FILER_DELAY", "0"))) # raise on free tier
if not rows:
sys.exit("no files found in inbox/")
with open("plan.csv", "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys())
w.writeheader()
w.writerows(rows)
print(f"\nwrote plan.csv: {sum(r['action'] == 'move' for r in rows)} move, "
f"{sum(r['action'] != 'move' for r in rows)} need a look")
if __name__ == "__main__":
main()
Copy 20 test scans into inbox/ and run:
python plan.py
Output looks like this:
[1/20] move scan_0001.pdf -> archive/utilities/2026/2026-03-14_pln_bill_electricity-bill-march.pdf
[2/20] move scan_0002.pdf -> archive/tax/2025/2025-08-02_bapenda-kota-bogor_tax-form_pbb-land-tax-2025.pdf
[3/20] review IMG_20260314_101522.jpg -> archive/other/undated/undated_toko-sumber-jaya_receipt_handwritten-nota.jpg
If you’re on the free tier and hit rate-limit errors (HTTP 429), set FILER_DELAY=5 (PowerShell: $env:FILER_DELAY="5") and run again. Failed files show up in the plan as skip with the error message, so nothing is silently dropped.
Step 4: Check the plan in a spreadsheet
Open plan.csv in Excel, LibreOffice or Google Sheets. The file is saved as UTF-8 with a BOM, so Excel shows non-English names correctly. This is the step that makes the tool safe:
- Filter
action=review. Open each of those files, fix thedestcolumn by hand, and changeactiontomove. Typical reasons: handwritten notas, no printed date, or a photo taken at an angle. - Skim the
moverows. Sort bycategory, thenissuer. Wrong ones stand out when they sit next to correct ones. Skimming a row takes seconds. Opening a PDF doesn’t. - Normalise issuer names. The model might write
PLNon one bill andPT PLN (Persero)on another. Use find-and-replace on thedestcolumn so they match, otherwise sorting by filename won’t group them. - Save as CSV, keeping the same columns.
Typed, printed documents with a clear letterhead usually come back move. Handwritten or undated papers usually come back review. Your own mix decides the split, so run 20 files first and count.
Step 5: Apply the plan, with undo
Save this as apply.py:
import csv, shutil
from pathlib import Path
undo = []
with open("plan.csv", encoding="utf-8-sig") as f:
rows = [r for r in csv.DictReader(f) if r["action"].strip().lower() == "move"]
for r in rows:
src, dest = Path(r["src"]), Path(r["dest"])
if not src.exists():
print(f"missing, skipped: {src}")
continue
if dest.exists():
print(f"destination exists, skipped: {dest}")
continue
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(src, dest)
undo.append({"from": str(dest), "back_to": str(src)})
print(f"moved {src.name} -> {dest}")
with open("undo.csv", "a", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=["from", "back_to"])
if f.tell() == 0:
w.writeheader()
w.writerows(undo)
print(f"{len(undo)} files moved, undo log in undo.csv")
And undo.py, in case you change your mind:
import csv, shutil
from pathlib import Path
with open("undo.csv", encoding="utf-8") as f:
for r in reversed(list(csv.DictReader(f))):
src, back = Path(r["from"]), Path(r["back_to"])
if src.exists() and not back.exists():
back.parent.mkdir(parents=True, exist_ok=True)
shutil.move(src, back)
print(f"restored {back}")
Run it:
python apply.py
apply.py never overwrites a file and never deletes anything. Rows you left as review or skip stay in inbox/ for the next round. Once you’re happy with a batch, rename undo.csv (for example to undo-2026-10-18.csv) so the next undo only reverses the next run.
Step 6 (optional): Make the scans searchable
Renaming tells you which file to open. A text layer lets you search inside scans with your normal file search or PDF reader. ocrmypdf is free, open source, and uses the Tesseract OCR engine:
# Debian/Ubuntu
sudo apt install ocrmypdf tesseract-ocr-ind
# macOS (Homebrew)
brew install ocrmypdf tesseract-lang
# add a text layer to every PDF in the archive, skipping ones that already have text
find archive -name "*.pdf" -exec ocrmypdf -l ind+eng --skip-text {} {} \;
Using the same filename for input and output replaces each file with its OCR’d version. The first time, run it on a backup copy of archive/ and open a few results before you do the whole thing.
Honest cost breakdown
Scenario: clearing a backlog of 1,000 documents, then about 50 new ones a month.
| Item | Backlog (one-off) | Monthly after |
|---|---|---|
| Gemini 3.5 Flash-Lite, ~$0.0015 per document | ~$1.50 | ~$0.08 |
| Re-runs, testing, failed calls | ~$0.50 | ~$0.05 |
| Python, ocrmypdf, spreadsheet | $0 | $0 |
| Total AI cost | ~$2 | under $0.20 |
Time: manual renaming at roughly one minute per document is about 17 hours for the backlog. With this tool you spend about an hour on setup, an hour or two skimming 1,000 plan rows, and fixing the review rows by hand. If 15% need review at a minute each, that’s another 2.5 hours. Altogether that’s roughly 5 hours instead of 17. These are illustrative numbers. Measure your own review rate on the first 50 files.
If too many faded or handwritten scans come back as review, move just those files into a fresh inbox/ and re-run with FILER_MODEL=gemini-3.8-flash and FILER_THINKING=low. It costs more per document (and its price doubles in January 2027), but a few cents is cheap next to your time spent fixing rows by hand.
Turning it into income
The buyer for this is anyone sitting on boxes of paper who has to find things in them: small accounting and tax practices (konsultan pajak), property agents, contractors, landlords with several units, family businesses, and households sorting a parent’s paperwork. None of the figures below are guaranteed. They’re a way to work out a price, not proven income.
1. One-off “archive cleanup” job. The client scans, or you do if you own a fast document scanner. You run the plan, review it, apply it, add OCR, and hand back a folder tree on a USB drive or their Google Drive. Bill for the hours. Scanning and review are most of the work, not the AI.
2. Setup on the client’s machine. Install the scripts on their computer with their own Gemini key on the paid tier, so the documents and the bill stay with them. Teach one staff member the plan → review → apply routine. That’s 2–3 hours of your time.
3. Accountants as a channel. A bookkeeper with 20 small clients receives a pile of unnamed scans every month. One accountant who uses this is a better lead than 20 separate shops.
How to price it: start from the hours the client would spend doing it by hand, or the staff wages for that time, and charge clearly below that. Use local rates. Don’t copy a dollar figure from a blog, including this one. “Your three boxes of papers, scanned, named and searchable by Friday” sells. “AI document intelligence” doesn’t.
Reality check: what does NOT work
Running it on your only copy. The scripts don’t delete anything, but a bug in your edits, a sync client fighting over the folder, or a mistyped dest can still make a mess. Copy the files into inbox/ and keep the original folder until you’ve checked the archive.
Skipping the plan review. The model returns confident JSON whether it’s right or not. The usual mistakes: picking the due date or print date instead of the issue date, reading 03/04/2026 the American way (March 4) when an Indonesian document means 3 April, and naming the recipient as the issuer on letters. check() catches impossible dates, not plausible wrong ones. That’s why you read the plan.
Putting client documents through the free tier. Under Google’s Gemini API terms, content sent on the unpaid tier can be used to improve Google’s products and may be seen by human reviewers. The paid tier doesn’t do this. Tax forms, ID cards and medical papers contain personal data, which in Indonesia falls under the Personal Data Protection Law (UU PDP, Law No. 27 of 2022). Turn on billing before any client data goes through. For this job it costs a couple of dollars.
Expecting it to fix bad scans. Skewed phone photos, shadows, and low-resolution greyscale scans of faded thermal receipts produce review rows and poor OCR. Scan at 300 dpi, flat, in good light. That’s the scanner’s job, not the model’s.
Multi-document PDFs. If someone scanned 30 different papers into one scan_batch.pdf, this tool sees the first two pages and names the whole file after the first document. Split batches first (most scanner software can start a new file per document or split on blank pages), or sort the plan by file size and handle the big ones by hand.
Password-protected PDFs. Bank e-statements are often encrypted. The script marks them skip with “password-protected PDF”. Rename those by hand, or remove the password with the one you already have before running.
Hard-coding the model name. Google retires and restricts model versions on a schedule (see ai.google.dev/gemini-api/docs/deprecations). That’s why the model is an environment variable. When you switch, set FILER_THINKING to the lowest thinking_level the new model accepts (3.8 Flash’s lowest is low, not minimal). Older 2.5 models don’t take thinking_level. Use thinking_budget=0 in the code instead. Then re-run your 20 test files and compare plans before switching over.
Too many categories. Forty categories means the model (and you) hesitate between suppliers and inventory on every invoice. Keep it to roughly 10–15 folders you actually browse, and rely on filenames and OCR search for the rest.
Final checklist
- Working on a copy of the files, originals kept until you’ve checked
- Categories and document types edited to match how you search
-
plan.pytested on 20 files, review rate counted - Every
reviewrow fixed by hand,moverows skimmed by category - Issuer names normalised before
apply.py - Billing enabled before any client or ID documents go through
-
undo.csvkept until you’re happy with the result - Optional:
ocrmypdftested on a backup first
Start with your own drawer of bills and letters. If the plan comes back mostly right and you find last year’s tax receipt in five seconds, you’ve got a tool for yourself and a before/after demo to show a client.