Organizing Documents with Some AI, ML, and Elbow Grease

In this first post of (likely) a multi-part series I'm going to discuss how I am using machine learning, AI, and good old-fashioned elbow grease to make sense of the 3000 files in my ~/Documents/Unfiled directory.

The Problem Statement

There are several contributing factors to the problem. Let's start with the obvious ones:

  • I'm a digital packrat
  • I'm a single parent of 3 (and therefore busy)
  • I can be lazy sometimes
  • I have ADHD, and get easily sidetracked from things I intended to do

When my dad passed away last year, it got even worse. Suddenly I was getting all of his mail, bills, correspondence, too. I didn't want to lose it; but I sure wasn't ready to read it all. So I scanned it and dropped it in the Unfiled folder.

So now we're here. Where here is a place where I can't find anything I need and my Documents directory is the definition of hot-mess.

The Goal

I'd like to take that folder of 3000 random unclassified documents and sort them into something more clear. I think sorting them by originating source (Supplier, Vendor, Biller, Organization) is a good first step. Eventually I'd like to sort them by date group too. Probably by Year, then Month.

For a bonus, I'd love to do a projected filesystem sort of thing in Windows and a Plan9 type server on Mac/Linux using FUSE. It'd be really convenient to be able to get at documents from a Filesystem interface by using different facets like keywords, dates, categories, etc. That might fit more cleanly with the way I think, too. But, again, that's a stretch goal, because we'll need all that metadata first.

If you're old enough to remember BeOS Filesystem, it would have solved nearly all of this. Someday we'll get back to the database/filesystem mashup that truly needs to exist.

The Solution(s)

First, there isn't really a one-step solution to this. It's going to take some work, and I can likely automate MOST of that. But there will still be a good portion of things I can't sort automatically.

Step One

As a first step, I wrote a small Go program that calls Azure Cognitive Services Vision API to do Optical Character Recognition on all the files that are compatible (PDF and image files). Nearly everything I have is in pdf format, but there are a few TIFF files in there too. This program is in flux right now, so I'm not going to release it as Open Source until it's settled a bit. If I forget - ping me on twitter @bketelsen or email mail@bjk.fyi - and remind me! Related: the code samples in this post are probably garbage, and won't likely match the end result that I publish. I'm sure I'm swallowing errors, and haven't done the slightest bit of refactor/cleanup on this code yet.

WARNING: Don't cut/paste this code yet, please.

I created a domain type appropriately called Document that stores metadata about files on disk:

type Document struct {
	Hash         string
	Path         string
	PreviousPath string
	Operation    *CognitiveOperation
	Results      *CognitiveReadResponse
}

I'll discuss the fields as they come up, but Path and PreviousPath should be obvious. Current and previous location on disk, so that I can account for file moves with at least a little bit of history.

The pricing for the OCR is really attractive - as of September, 2019 it is:

0-1M transactions — $1.50 per 1,000 transactions

I know that I'll be fine tuning the processes that run, and likely running them repeatedly. I wanted to find a way to store the results from the OCR for each document, but I am also aware that I can't use the document name and path as the canonical key to find the document later, because the goal of this app is to move them and rename them appropriately! So I decided to use a hash of the file contents as a key. SHA256 seems to be the right algorithm for file contents, low cost computation, low collision chance. So I created a hash function that calculates the SHA256 hash of the document after it is read:

func (d *Document) GetHash() {

	f, err := os.Open(d.Path)
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		log.Fatal(err)
	}
	d.Hash = fmt.Sprintf("%x", h.Sum(nil))
}

After getting the results of the OCR operation, I set them in the Document type, then persist the metadata to disk in a hidden directory. Currently that's ~/.classifier/ but, as with all of this, it might change in the future.

The file is stored using the SHA256 hash of the contents as the file name, and the Document type is serialized to disk using Go's efficient and lightweight encoding/gob format. While I'm debugging and playing with this code, I decided to also persist the data in json format so it's easier to read. Here's the method on Document that saves/serializes to disk:

func (d *Document) SaveMetadata() error {
  fmt.Println(d.Hash)
  //TODO use new XDG config dir location
  // https://tip.golang.org/pkg/os/#UserConfigDir
	filePath := "/home/bjk/.classifier/" + d.Hash // TODO FILEPATH.JOIN
	fmt.Println(filePath)
	file, err := os.OpenFile(filePath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return err
	}
	defer file.Close()
	enc := gob.NewEncoder(file)
	err = enc.Encode(d)
	if err != nil {
		return err
	}
	jfilePath := "/home/bjk/.classifier/" + d.Hash + ".json" // TODO FILEPATH.JOIN
	fmt.Println(jfilePath)
	jfile, err := os.OpenFile(jfilePath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return err
	}
	defer jfile.Close()
	jenc := json.NewEncoder(jfile)
	return jenc.Encode(d)
}

Lots of bad things happening in there, see above caveats about copying/pasting this code. The important part is the encoding in gob format of the contents of the Document metadata, which is then saved to disk using the SHA256 hash as the filename. This is a nice future-proof solution, and provides several benefits.

  • If there is already a file with the same name, it's been processed once.
  • If the .Path is different from the document I'm inspecting, I might have an exact duplicate, which is a candidate for (soft) deleting
  • It doesn't matter where the files get moved, as long as the SHA256 hash matches, I've got the metadata saved already.

This is a very low-tech metadata database, of sorts. It's definitely not optimized for real-time use, but instead for batch operations.

Keeping all the metadata in this format means I can write any number of other tools to read and modify the metadata without worrying too much.

Step Two

At this point, I have a directory full of unprocessed files and a way to process them once and save the results so I don't have to re-process them later. It's time to fire off the processing app. I used cobra to build the command-line utility, so I made the root/naked command do the actual calls to Azure Cognitive Services:

go build
./classifier

This iterates over every file in the ~/Documents/Unfiled directory, calling Cognitive Services OCR for the file types that are supported. There is no current mechanism to retrieve metadata from other document types (Word documents, text files, etc). That's a future addition.

After receiving the results, the responses are serialized using the above mentioned gob serialization into ~/.classifier/HASH

Classification

Based on the results there are some simple bag of words matches that can be done. Some of the documents I have contain very unique text that is indicative of a particular document type. For example, Bank of America always includes my account number and their address in Wilmington. No other document in my corpus has those two distinct things together, so I can write a simple classifier for all Bank of America documents. I decided to use simple TOML for a configuration file here:

[[entity]]
name = "Bank of America"
directory = "BOA"
keywords = ["Bank of America","12345677889","Wilmington"]

Here, I added a sub-command in cobra so I can classify files without re-posting them to Cognitive Services. So I added the classifier process command:

./classifier process

It currently goes through all the files in Unfiled and checks their metadata for matches against the TOML file. This worked perfectly for several of my external correspondents. It took all the documents from Unfiled and placed them in Filed/{directory}.

What About The Rest?

There are many documents that aren't easily processed this way though. My next inspiration came in the shower (of course). If you squint enough, or are far enough away, all documents from the same entity of the same type look the same. So all my mortgage statements look the same, but the numbers are different.

I installed ImageMagick, and wrote a script to make a low-resolution thumbnail of each PDF. I made the resolution low enough that the text isn't readable even if you magnify the image.

Then I searched for ways to compare images and came across duplo, which appears to do what I need. It does a hash of the image and allows you to compare other documents to that hash to find a similarity score. Using this type of process my next goal is to group similar documents together by searching for ones with matching or close-to-matching image hashes.

But that'll be probably next weekend. It's been really fun doing this much, and I'm looking forward to seeing how much more I can learn as I go!

Intermediate results:

Before:

2846 Files

After:

Unfiled\
  2710 Files
Filed\
  136 Files in 2 Directories