The Papertrail: AI PDF Renaming and the Tokens That Make It Interesting
Abstract
Everyone has a Downloads folder full of “scan0023.pdf” and “document(3)-final-FINAL.pdf”. Renaming them by content sounds trivial — read the file, understand what it is, give it a name. The implementation reveals something useful about how LLMs actually handle text: what a token is, why context windows matter in practice, why you want structured output instead of prose, and why heuristics should go first. The project, now called Folionym, is at github.com/sebastianspicker/folionym.
Contents
The project has since been renamed and refactored as Folionym, at
github.com/sebastianspicker/folionym;
the old AI-PDF-Renamer address redirects there. Where this post says “the
tool”, it means Folionym. Its current source identifies itself as 0.4.0a1, an
alpha candidate.
Repository configuration and Ollama documentation checked through 2026-07-11. Model names, context settings, endpoint compatibility, and remote-service privacy are configuration-dependent and should be rechecked against the installed release before use.
The Problem
Every PDF acquisition pipeline eventually produces the same chaos.
Journal articles downloaded from publisher sites arrive as
513194-008.pdf or 1-s2.0-S0360131520302700-main.pdf. Scanned
letters from the tax authority arrive as scan0023.pdf. Invoices arrive
as Rechnung.pdf — every invoice from every vendor, overwriting each
other if you are not paying attention. The actual content is
in the file. The filename tells you nothing.
The human solution is trivial: open the PDF, glance at the title or date or sender, type a descriptive name. Thirty seconds per file, multiplied by several hundred files accumulated over a year, becomes a task that perpetually does not get done.
The automated solution sounds equally trivial: read the text, decide what the document is, generate a filename. What could be involved?
Quite a bit, it turns out. Working through the implementation is a useful way to make concrete some things about LLMs and text processing that are easy to understand in the abstract but clearer with a specific task in front of you.
Step One: Getting Text Out of a PDF
A PDF is not a text file. It is a binary format designed for page layout and print fidelity — it encodes character positions, fonts, and rendering instructions, not a linear stream of prose. The text in a PDF has to be extracted by a parser that reassembles it from the position data.
For PDFs with embedded text (most modern documents), this works well
enough. For scanned PDFs — images of pages, with no embedded text at all —
you need OCR as a fallback. Folionym extracts native text with PyMuPDF first. OCR through OCRmyPDF is
optional and off by default (--ocr); when enabled, it runs if native
extraction yields fewer than 50 characters (MIN_CHARS_BEFORE_OCR in
src/folionym/extraction/pdf.py).
The result is a string. Already there are failure modes: two-column layouts produce interleaved text if the parser reads left-to-right across both columns simultaneously; footnotes appear in the middle of sentences; tables produce gibberish unless the parser handles them specifically. These are not catastrophic — for renaming purposes, the first paragraph and the document header are usually enough, and those are less likely to be badly formatted than the body. But they are real, and they mean that the text passed to the next stage is not always clean.
Step Two: The Token Budget
Once you have a string representing the document’s text, you cannot simply pass all of it to a language model. Two reasons: context windows have hard limits, and — even when they are large enough — filling them with the full text of a thirty-page document is wasteful for a task that only needs the title, date, and category.
Language models do not process characters directly. They process tokens — units produced by a model-specific tokenizer, often a subword tokenizer but not necessarily the same BPE scheme I described in the strawberry post. A rough practical rule for English text is:
$$N_{\text{tokens}} \;\approx\; \frac{N_{\text{chars}}}{4}$$This is an approximation — technical text, non-English content, and
code tokenise differently — but it is useful for budgeting. A ten-page
academic paper might contain around 30,000 characters, which is
approximately 7,500 tokens under a suitable tokenizer. The repository’s apple-silicon preset
uses qwen2.5:3b via Ollama and documents a 32K context target; the gpu preset documents
qwen2.5:7b-instruct and 128K. Actual usable context depends on the model, runtime, and configured
limits. The prompt and response also consume context.
The tool defaults to 28,000 tokens of extracted text
(DEFAULT_MAX_CONTENT_TOKENS, overridable with FOLIONYM_MAX_TOKENS), leaving
headroom for the prompt and response in most configurations. For documents that
exceed this, the extraction is truncated to the front of the text, on the
reasonable assumption that titles, dates, and document types appear early. The
count uses tiktoken when the optional tokens extra is installed and
otherwise falls back to the four-characters-per-token rule above.
This truncation is a design decision, not a limitation to be apologised for. For the renaming task, the first two pages of a document contain everything the filename needs. A strategy that extracts the first page plus the last page (which often has a date, a signature, or a reference number) would work for some document types. The current implementation keeps it simple: take the front, stay within budget.
Step Three: Heuristics First
Here is something that improves almost any LLM pipeline for structured extraction tasks: do as much work as possible with deterministic rules before touching the model.
Folionym applies a scoring pass over the extracted text. The scores come from
regex patterns in the bundled heuristic_scores.json. The heuristics are regex-based
rules that look for patterns likely to appear in specific document types:
- Date patterns, handled in
src/folionym/naming/dates.py: ISO and compact dates, day-month-year forms such as15.03.2025, German and English long-form dates, and month-year forms - Category patterns: weighted regexes such as the invoice markers
(
rechnungs\s*nummer,invoice\s*number,vat\s*invoice) in the bundledsrc/folionym/data/heuristic_scores.json - Category aliases and stopwords, in the bundled
category_aliases.jsonandmeta_stopwords.json, plus an optional rules file for overrides
Each pattern that fires contributes a score to a category. How much that score
spares the model is configurable and, by default, limited. With the LLM enabled
(the default), the model is still asked for a summary and keywords; the category
request is skipped only when both skip_llm_category_if_heuristic_score_ge and
skip_llm_category_if_heuristic_gap_ge are set and the heuristic category
clears them, and both default to unset. The LLM is skipped for a document when
--no-llm is given, or when a rules file lists its heuristic category under
skip_llm_if_heuristic_category. In those cases the file is renamed from the
heuristic output.
This matters because heuristics can be deterministic and avoid a model call. Their absolute speed and the share of documents they handle depend on hardware, rules and corpus; no benchmark for the former microseconds-versus-seconds or “most invoices” claims was supplied here.
The LLM is intended to supply what rules cannot: a summary and keywords for unusual formats, mixed languages or ambiguous document types, and a category when the heuristic is unsure. The repository review did not establish a representative 20–40% fallback rate.
Step Four: What to Ask the LLM, and How
Unless --no-llm or a rule skips it, the pipeline builds a prompt from the
extracted text and sends it to the local endpoint. What the prompt asks for matters enormously.
The naive approach: “Please rename this PDF. Here is the content: [text].” The response will be a sentence. Maybe several sentences. It will not be parseable as a filename without further processing, and that further processing is itself an LLM call or a fragile regex.
The better approach: ask for structured output. The prompt in
src/folionym/llm/prompts.py requests a JSON object with three fields, in
the English prompt:
{
"summary": "1-2 precise sentences",
"keywords": ["KW1", "KW2", "KW3", "KW4", "KW5", "KW6"],
"category": "Category"
}
The rules in the prompt ask for a 1–2 sentence summary and 5–7 keywords. The
date is not part of this request; it comes from the date patterns above. The
model returns JSON, and the response parser in src/folionym/llm/parsing.py
validates it and can accept lenient JSON for list fields such as keywords.
The naming code then filters stopwords, keeps the first three keywords and the
first five summary tokens, and cleans each token before assembling the
filename (_build_metadata_tokens in src/folionym/naming/metadata.py).
Structured JSON creates a schema that can be parsed and validated. Reliability still depends on model, runtime, decoding, prompt and document; no cross-model failure rate or training-data comparison was measured for this article.
Schema checks can reject shape and allowed-value errors. They do not by themselves prove that a plausible date or keyword is supported by the source; that requires source-grounded validation and human review in the dry run.
Step Five: The Filename
The output format is YYYYMMDD-category-keywords-summary.pdf. A few
design decisions embedded in this:
Date first. Lexicographic sorting of filenames then gives you chronological sorting for free. This is the most useful sort order for most document types — you want to find the most recent invoice, not the alphabetically first one.
Lowercase, hyphens by default. No spaces (which require escaping in many
contexts), no special characters (which are illegal in some filesystems
or require quoting), no uppercase (which creates case-sensitivity issues
across platforms). The token cleaning in src/folionym/naming/tokens.py transliterates umlauts,
removes characters that are illegal in Windows filenames, lowercases, and
replaces whitespace with underscores; the default case style is kebabCase,
joined with hyphens (desired_case, with camelCase and snakeCase as
alternatives).
Collision resolution. Two documents with the same date, category,
keywords, and summary would produce the same filename. The resolver
appends a counter suffix (_1, _2, …) when a target name already
exists (src/folionym/rename_ops/execution.py). Order-independent naming would additionally require a stable sort and
tie-break rule; that property was not execution-tested in this review.
Local-First
The repository’s Apple-Silicon and GPU presets use Ollama at http://127.0.0.1:11434; its
documented configured completion URL is http://127.0.0.1:11434/v1/completions.
Ollama’s current OpenAI-compatibility documentation prominently shows /v1/chat/completions,
so endpoint compatibility is a release/configuration check rather than a universal API fact. This
local default is a deliberate choice
for a document management tool. The documents being renamed are likely
to include medical records, financial statements, legal correspondence —
content that should not be routed through an external API by default.
The default preset (apple-silicon) is a 3B Qwen 2.5 model, while the GPU preset is a 7B instruction model; model
choice, latency, and extraction quality depend on hardware, runtime, language, scan quality, and
the documents. This is a utility task that can often use a small local model, but suitability
should be established with a representative dry run rather than inferred from parameter count.
For users who want to use a remote API, the endpoint is configurable. That changes the data-flow and privacy boundary; the local default is a sensible starting point, not a guarantee once a remote endpoint, proxy, hook, or third-party OCR service is configured.
What It Cannot Do
Renaming is a classification problem disguised as a text generation problem. The tool works well when documents have standard structure — title on page one, date near the header or footer, document type identifiable from a few keywords. It works less well for documents that are structurally atypical: a hand-written letter scanned at poor resolution, a PDF that is essentially a single large image, a document in a language the model handles badly.
With --no-llm, or when the LLM returns nothing usable, naming rests on the
heuristic category. The rename log and the folionym-undo command mean that a
bad batch run can be reversed, subject to the constraints in the README. These
are not complete solutions to the hard cases, but they are the right
design response to a tool that handles real-world document noise.
The harder limit is semantic: the tool can tell you that a document is an invoice and extract its date and vendor name. It cannot tell you whether the invoice has been paid, whether it matches a purchase order, or whether the amount is correct. For those questions, renaming is just the first step in a longer pipeline.
The code is at github.com/sebastianspicker/folionym. The tokenisation background in the extraction and budgeting sections connects to the strawberry tokenisation post and the context window post.
Changelog
- 2026-04-02: Corrected the default model name from
qwen3:8btoqwen2.5:3b. The codebase default isqwen2.5:3b(apple-silicon preset) orqwen2.5:7b-instruct(gpu preset). - 2026-04-02: Corrected
DEFAULT_MAX_CONTENT_TOKENSdescription from “28,000 characters … roughly 7,000 tokens” to “28,000 tokens.” The variable is a token limit, not a character limit. - 2026-07-11: Corrected tokenizer, model, endpoint, privacy, benchmark, JSON-validation, fallback-rate, and collision-order claims to distinguish documented defaults from untested behaviour.
- 2026-10-03: Updated the repository link and implementation details after
the project was renamed and refactored as Folionym: new address, file paths
(
llm/prompts.py,llm/parsing.py,naming/), prompt fields (1–2 sentence summary, 5–7 keywords, with three keywords and five summary tokens kept in the filename), collision suffixes (_1,_2), optional OCR, and when the LLM is skipped.