- Posted on
- • Artificial Intelligence
AI-Powered File Classification
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
AI-Powered File Classification with Bash: Turn Chaos into Structure
Ever opened your Downloads folder and felt a mix of dread and resignation? PDFs, invoices, logs, photos, archives—thousands of files with cryptic names and no structure. Traditional tools like file, grep, and MIME types help, but they can’t tell you if a PDF is an invoice, a resume, or a research paper.
What if your shell could? With a few battle-tested CLI tools and an LLM (local or cloud), you can auto-extract content, classify files by meaning, tag them, and move them into a clean, auditable structure—all from Bash.
This guide shows you how.
Why AI-powered classification is worth it
Content awareness beats guesses: Filenames and extensions lie; content doesn’t. AI can read snippets and classify semantically.
Reproducible workflows: Scriptable, explainable, and adaptable—no manual dragging and dropping.
Privacy-first options: Run models locally with
ollamaor use an OpenAI-compatible API if you prefer hosted models.Faster retrieval and compliance: Tags like “finance”, “HR”, “legal”, “code”, “photos” get your files in the right bins—plus optional sensitivity labels.
What we’ll build
A Bash-based pipeline that:
1. Extracts text and metadata from common file types (PDF, DOCX, text, images via OCR).
2. Feeds a compact “file summary” to an LLM with a strict JSON schema.
3. Parses the model’s response with jq.
4. Applies actions: move to category folders, set xattrs, and print a CSV log.
You get two backends:
Local:
ollamawith a small, fast model.Cloud: any OpenAI-compatible chat API via
curl.
Prerequisites and installation
We’ll install only widely-available packages. Commands for apt, dnf, and zypper are provided.
Core tools we’ll use:
jq(JSON parsing)file(MIME detection)ripgrep(optional keyword fallback)exiftool(metadata; package names vary)pdftotext(from poppler)tesseract-ocr(optional OCR for images)docx2txt(for .docx)attr(for xattrs, optional)curl(for API calls)
Install them with:
- Debian/Ubuntu (apt):
sudo apt update
sudo apt install -y jq ripgrep file libimage-exiftool-perl poppler-utils tesseract-ocr docx2txt attr curl
- Fedora/RHEL/CentOS Stream (dnf):
sudo dnf install -y jq ripgrep file perl-Image-ExifTool poppler-utils tesseract docx2txt attr curl
- openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y jq ripgrep file perl-Image-ExifTool poppler-tools tesseract docx2txt attr curl
Optional: local LLM with ollama (works on most modern distros):
curl -fsSL https://ollama.com/install.sh | sh
# Optionally enable at boot (if installed as a service)
sudo systemctl enable --now ollama 2>/dev/null || true
# Pull a small general model
ollama pull llama3:8b
Cloud option (OpenAI-compatible):
- Set your key and endpoint (OpenAI example):
export OPENAI_API_KEY="sk-..."
export OPENAI_BASE_URL="https://api.openai.com/v1"
export OPENAI_MODEL="gpt-4o-mini"
- Or for an OpenAI-compatible self-hosted endpoint, set
OPENAI_BASE_URLaccordingly.
The script: ai_classify.sh
What it does:
Extracts a compact text snippet + metadata from each file.
Prompts an LLM to return strict JSON:
- top_category: one-of preset categories or “other”
- secondary_tags: array of keywords
- sensitivity: one of public/internal/confidential
- suggested_name: a clean filename stem
- confidence: 0.0–1.0
Moves the file into
./classified/<top_category>/.Optionally sets xattrs with tags (if
attris installed).Writes a CSV log to
classification_log.csv.
Save this as ai_classify.sh and make it executable: chmod +x ai_classify.sh.
#!/usr/bin/env bash
set -euo pipefail
# Configuration
ALLOWED_CATEGORIES=("finance" "legal" "hr" "code" "logs" "media" "photos" "audio" "video" "personal" "work" "education" "research" "receipts" "invoices" "contracts" "medical" "travel" "other")
MAX_BYTES=5000
OUTDIR="${OUTDIR:-classified}"
DRY_RUN=0
BACKEND="${AI_BACKEND:-auto}" # auto|ollama|openai
OLLAMA_MODEL="${OLLAMA_MODEL:-llama3:8b}"
OPENAI_MODEL="${OPENAI_MODEL:-${OPENAI_MODEL:-gpt-4o-mini}}"
OPENAI_BASE_URL="${OPENAI_BASE_URL:-https://api.openai.com/v1}"
LOGFILE="${LOGFILE:-classification_log.csv}"
usage() {
echo "Usage: $0 [-n] DIRECTORY"
echo " -n dry run (no moves, just print results)"
exit 1
}
while getopts ":n" opt; do
case "$opt" in
n) DRY_RUN=1 ;;
*) usage ;;
esac
done
shift $((OPTIND-1))
[ $# -eq 1 ] || usage
ROOT="$1"
[ -d "$ROOT" ] || { echo "Not a directory: $ROOT" >&2; exit 2; }
need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing dependency: $1" >&2; exit 3; }; }
need file
need jq
have() { command -v "$1" >/dev/null 2>&1; }
# Backend autodetect
detect_backend() {
if [ "$BACKEND" != "auto" ]; then
echo "$BACKEND"
return
fi
if have ollama; then
echo "ollama"
elif [ -n "${OPENAI_API_KEY:-}" ]; then
echo "openai"
else
echo "none"
fi
}
BACKEND="$(detect_backend)"
if [ "$BACKEND" = "none" ]; then
echo "No AI backend available. Install ollama or set OPENAI_API_KEY." >&2
exit 4
fi
json_list_categories() {
printf '['
local first=1
for c in "${ALLOWED_CATEGORIES[@]}"; do
[ $first -eq 1 ] || printf ','
printf '"%s"' "$c"
first=0
done
printf ']'
}
extract_text() {
# Outputs up to MAX_BYTES of representative text to stdout
local f="$1"
local mime
mime="$(file -b --mime-type -- "$f" || true)"
case "$mime" in
text/*|application/json|application/xml)
head -c "$MAX_BYTES" -- "$f" 2>/dev/null || true
;;
application/pdf)
if have pdftotext; then
pdftotext -q "$f" - -layout 2>/dev/null | head -c "$MAX_BYTES" || true
fi
;;
application/vnd.openxmlformats-officedocument.wordprocessingml.document)
if have docx2txt; then
docx2txt "$f" - 2>/dev/null | head -c "$MAX_BYTES" || true
fi
;;
image/*)
if have tesseract; then
tesseract "$f" stdout 2>/dev/null | head -c "$MAX_BYTES" || true
fi
;;
*)
# Try simple text read in case it's actually text
head -c "$MAX_BYTES" -- "$f" 2>/dev/null || true
;;
esac
}
file_summary_json() {
# Emits a compact JSON summary for LLM
local f="$1"
local name base ext size mime sha1 meta ex
name="$(basename -- "$f")"
base="${name%.*}"
ext="${name##*.}"
size="$(stat -c %s -- "$f" 2>/dev/null || stat -f %z -- "$f")"
mime="$(file -b --mime-type -- "$f" || true)"
sha1="$(sha1sum -- "$f" 2>/dev/null | awk '{print $1}')"
meta=""
if have exiftool; then
# Grab a few helpful metadata fields if present (silently ignore errors)
meta="$(exiftool -s -s -s -Title -Subject -Keywords -Author -Creator -CreateDate -DocumentName -- "$f" 2>/dev/null | head -n 20 | tr '\n' ';' | sed 's/"/\\"/g')"
fi
ex="$(extract_text "$f" | tr -d '\000' | head -c "$MAX_BYTES" | sed 's/"/\\"/g')"
printf '{"name":"%s","ext":"%s","size":%s,"mime":"%s","sha1":"%s","metadata_hint":"%s","content_snippet":"%s"}' \
"$name" "$ext" "${size:-0}" "$mime" "$sha1" "${meta:-}" "${ex:-}"
}
build_prompt() {
local summary="$1"
local categories
categories="$(json_list_categories)"
cat <<EOF
You are an expert file classifier. Return ONLY strict minified JSON with keys:
{"top_category": string in $categories,
"secondary_tags": array of 1-6 short lowercase tags,
"sensitivity": one of ["public","internal","confidential"],
"suggested_name": short kebab-case stem without extension,
"confidence": number 0.0-1.0}
Rules:
- Base decisions on provided content snippet, metadata_hint, name, and mime.
- Prefer exact categories from the list; use "other" if none fit.
- Keep JSON on one line. No code fences, no commentary.
File summary:
$summary
EOF
}
classify_with_ollama() {
local prompt="$1"
ollama run "$OLLAMA_MODEL" -p "$prompt" 2>/dev/null
}
classify_with_openai() {
local prompt="$1"
curl -sS "$OPENAI_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<JSON
{
"model": "${OPENAI_MODEL}",
"temperature": 0.2,
"response_format": { "type": "json_object" },
"messages": [
{"role":"system","content":"You are a careful, deterministic file classifier."},
{"role":"user","content": $(jq -Rs . <<< "$prompt")}
]
}
JSON
}
ensure_log_header() {
if [ ! -f "$LOGFILE" ]; then
echo "timestamp,sha1,src_path,top_category,secondary_tags,sensitivity,suggested_name,confidence" > "$LOGFILE"
fi
}
classify_file() {
local f="$1"
local summary prompt raw out json_line top tags sens name conf catdir stem ext newname dest
summary="$(file_summary_json "$f")"
prompt="$(build_prompt "$summary")"
case "$BACKEND" in
ollama)
raw="$(classify_with_ollama "$prompt" || true)"
json_line="$(echo "$raw" | tr -d '\n' | sed -E 's/^.*(\{.*\}).*$/\1/')" # best-effort
;;
openai)
raw="$(classify_with_openai "$prompt" || true)"
json_line="$(echo "$raw" | jq -rc '.choices[0].message.content' 2>/dev/null || true)"
;;
esac
# Validate JSON
out="$(echo "$json_line" | jq -rc '.' 2>/dev/null || true)"
if [ -z "$out" ]; then
echo "WARN: Failed to parse JSON for $f; raw:" >&2
echo "$raw" >&2
return 1
fi
top="$(echo "$out" | jq -r '.top_category')"
tags="$(echo "$out" | jq -c '.secondary_tags')"
sens="$(echo "$out" | jq -r '.sensitivity')"
name="$(echo "$out" | jq -r '.suggested_name')"
conf="$(echo "$out" | jq -r '.confidence')"
# Prepare destination
ext="${f##*.}"
stem="${name:-$(basename "$f" | sed 's/\.[^.]*$//')}"
[ -n "$top" ] || top="other"
catdir="$OUTDIR/$top"
mkdir -p -- "$catdir"
newname="${stem}.${ext}"
dest="$catdir/$newname"
# Avoid overwrite
if [ -e "$dest" ]; then
dest="${catdir}/${stem}-$(date +%s).${ext}"
fi
# xattrs for tags if available
if have setfattr; then
: # no-op
elif have attr; then
: # attr pkg provides setfattr
fi
if have setfattr; then
setfattr -n user.tags -v "$(echo "$tags" | tr -d '[]"' | tr ',' ' ')" -- "$f" 2>/dev/null || true
setfattr -n user.sensitivity -v "$sens" -- "$f" 2>/dev/null || true
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "[DRY] $f -> $dest | cat=$top tags=$tags sens=$sens conf=$conf"
else
mv -n -- "$f" "$dest"
echo "Moved: $f -> $dest (cat=$top, sens=$sens, conf=$conf)"
fi
# Log CSV
ensure_log_header
local sha1
sha1="$(sha1sum -- "$dest" 2>/dev/null | awk '{print $1}')"
printf '%s,%s,"%s",%s,"%s",%s,%s,%s\n' \
"$(date -Is)" "$sha1" "$dest" "$top" "$(echo "$tags" | tr -d '"')" "$sens" "$stem" "$conf" \
>> "$LOGFILE"
}
export -f classify_file file_summary_json extract_text build_prompt classify_with_ollama classify_with_openai
# Walk the directory
find "$ROOT" -type f ! -path "$OUTDIR/*" -print0 | while IFS= read -r -d '' f; do
# Skip obviously binary archives to save tokens; you can remove this if needed.
case "$f" in
*.zip|*.tar|*.gz|*.bz2|*.xz|*.7z) echo "Skip archive: $f"; continue ;;
esac
classify_file "$f" || true
done
Usage examples:
- Dry run first:
./ai_classify.sh -n ~/Downloads
- Then commit to moves:
./ai_classify.sh ~/Downloads
Result:
Files land in
./classified/<category>/.A
classification_log.csvcaptures every decision.If
attris installed, tags/sensitivity are stored in xattrs.
Actionable steps and real-world examples
1) Start with local-only privacy
Install
ollama, pullllama3:8b.Run the script with
AI_BACKEND=ollama:
AI_BACKEND=ollama OLLAMA_MODEL=llama3:8b ./ai_classify.sh -n ~/Downloads
Verify categories and tags, then rerun without
-n.Great for laptops handling confidential documents.
2) Add OCR and DOCX for broader coverage
Ensure
tesseract-ocranddocx2txtare installed.The script will extract text from scans and Word docs automatically.
Example: scanned receipts now land under
receiptswith tags like ["flight","hotel"].
3) Use a cloud model for higher accuracy
- Set:
export OPENAI_API_KEY="sk-..."
export OPENAI_MODEL="gpt-4o-mini"
AI_BACKEND=openai ./ai_classify.sh -n ~/inbox
- Cloud models often provide better classification on messy content (e.g., invoices vs. quotes).
4) Enforce your taxonomy
Update the
ALLOWED_CATEGORIESlist in the script to match your org (e.g., ["sops","tickets","design","sre","compliance"]).The model will choose “other” if nothing fits, keeping your tree clean.
5) Audit-friendly logs and xattrs
The CSV log becomes your audit trail.
With
attr, tags persist with the file; tools likegetfattr -d file.pdfshow them.Combine with
ripgrepfor rule-based fallbacks:
rg -n --json 'invoice|total due|VAT' classified/finance
Real-world wins:
Finance: PDFs named “scan0001.pdf” get classified as
invoicesorreceiptswith vendor tags.Engineering:
.logand.txtget split intologsvscodewith service names as tags.HR/Legal: Contracts and resumes surface under
legal/hrwith sensitivity “confidential”.Photos: OCR catches embedded text (“boarding pass”, “ID”) and routes to
travelorpersonal.
Tips and extensions
Rate limits: For large folders, throttle with
find ... | xargs -0 -n 10 -P 2 ./ai_classify.shby adapting the script into a function or batch.Cost control: Use
MAX_BYTESto keep prompts small; prefer local models for huge archives.Safer moves: Run with
-nfirst, then on the log inspect outliers (lowconfidence).Renames only: Comment out
mvand just use xattrs + logs if you don’t want to move files.Additional extractors: Add handlers for
.eml(useripmime), spreadsheets (ssconvertorin2csv), or audio (pipe transcripts from Whisper if installed).
Conclusion and next steps
You don’t need a heavyweight DMS to tame your filesystem. A careful mix of standard Linux tooling and an LLM backend can classify, tag, and file your documents semantically—all from Bash.
Next steps:
Install the prerequisites for your distro (apt/dnf/zypper above).
Choose your backend: local
ollamaor an OpenAI-compatible API.Run the script with
-n, adjust categories, then organize for real.
Got a unique taxonomy or tricky file types? Extend the extractors and category list—then let your shell do the heavy lifting.