Reading the Fine Print
How my political mail catalog reads each piece's client, state, and date with regular expressions, by ignoring the message and reading the two lines every piece carries for someone else: the legal disclaimer and InDesign's export stamp.
Every piece in my catalog of political mail needs three facts before staff can find it: who paid for it, which state it ran in, and when it was made. Those three facts are the search filters. A designer looking for last cycle’s Ohio mailers for one client doesn’t need a query at all; they pick a client, a state, and a year.
Claude reads every piece and writes a summary, and the summary often names the candidate. But a summary is written for people, and it phrases things differently every time. Rules can be read line by line, tested against labeled pieces, and fixed when they’re wrong. So the three fields come from a rules layer: a few hundred lines of regular expressions that run over the text Claude extracts from each piece.
The idea that made it work was to stop reading the message. The headline, the photo caption, and the body copy are all written to persuade, and they vary endlessly. But almost every piece also carries two lines that are written for someone else, in a predictable format: a disclaimer written for the law, and a timestamp written by InDesign.
The fine print every piece carries
Campaign mail in the US has to say who paid for it, so nearly every piece has a line that starts “Paid for by”. In the 50 pieces I labeled by hand to test the rules, 48 had one. That line names the committee, often its treasurer, and usually a mailing address, which means it also carries a state.
The second line comes from the design tool. When a designer exports a PDF from InDesign with page information turned on, InDesign prints a line outside the trim: the file name, the page number, and the date and time of the export. Nobody reading the mailer ever sees it, but Claude reads the whole rendered page, margins included, so it ends up in the extracted text.
Here is an invented piece, as Claude might extract it:
DANA BROOKS
Lower drug prices. Protect Medicare.
Dana Brooks will fight to cap what seniors pay for prescriptions.
Vote early by November 5.
Paid for by Committee to Elect Dana Brooks, Pat Lee, Treasurer
PO Box 1234, Dayton, OH 45402
Brooks_GOTV_Mailer_v3.indd 1 10/14/24 3:12 PM
Everything the rules layer needs is in the last four lines. Run through the real extractor, this piece comes out as the committee “Committee to Elect Dana Brooks”, the candidate Dana Brooks, the state OH, and the date 2024-10-14, each at high confidence.
Dates: scoring every timestamp
A piece can contain many dates: the election, an early-voting deadline, a date in a quoted news story. The rules layer finds every date that has a time attached, scores each one by its surroundings, and keeps the best:
DATE_REGEX = re.compile(
r'\b(\d{1,2}/\d{1,2}/\d{2,4})\s+(\d{1,2}:\d{2}(?::\d{2})?(?:\s*[AP]M)?)',
re.IGNORECASE
)
The scoring is where the knowledge lives:
| Signal | Points |
|---|---|
A .indd file name in the 120 characters before it |
+3 |
| Has a time of day | +2 |
| Has AM or PM | +1 |
| The same date appears more than once (the stamp repeats on every page) | +1 |
| Follows a word like “on”, “by”, “since”, or “until” (body copy) | −2 |
| Sits in a source or footnote | −2 |
A candidate scoring 3 or more is high confidence, 1 or 2 is medium, and anything at zero or below is thrown away. In the example, “10/14/24 3:12 PM” earns the first three signals for 6 points, and on a piece with more than one page it would earn the fourth as well.
This makes the field mean something specific: the date the design was exported, not the date of the election it was for. I keep it that way on purpose. When a piece has no stamp, its date stays blank, even when I review it by hand, rather than being guessed from the copy. One column with one meaning is worth more than a column that is always filled.
The date then has to fall inside a window, from 2019, when the archive starts, to a year after the current one. A misread like 2062 is caught and sent to review instead of being saved.
On the labeled set, the rules gave a year for 31 of the 33 pieces that have one, and all 31 were right. The other two had no stamp the rules could find.
Clients: from disclaimer to a name
The client comes from the disclaimer, in stages. The first regex finds the line and allows for the wording that sometimes comes before it:
PAID_FOR_BY_REGEX = re.compile(
r'(?:not\s+authorized[^.]+?\.?\s*)?'
r'(?:authorized\s+and\s+)?'
r'(?:political\s+ad\s+)?'
r'paid\s+for\s+by\s+(.+)',
re.IGNORECASE
)
Then the line is cleaned. It keeps only the first line, before the address block. It drops anything after a pipe, trailing punctuation, and the “not authorized by any candidate” sentence that independent groups have to print. Then it strips the treasurer or registered agent, a person whose name follows the committee’s after a comma:
# ", Pat Lee, Treasurer" → removed
cleaned = re.sub(
r',\s*[A-Z][A-Za-z]+\s+[A-Z][A-Za-z]+(?:\s*,?\s*(?:Treasurer|Chairman|Chair|'
r'Secretary|Registered\s+Agent|Agent|Director|President))?\s*$',
'', cleaned, flags=re.IGNORECASE
).strip()
There is a second version of that pattern for all-caps disclaimers, and another for “Registered Agent: Name”. Congressional mail is a special case. A member of Congress mailing constituents uses official funds, and the disclaimer says so instead of naming a committee, so the rules check for that wording and fall back to reading the candidate from the body.
What’s left of the line is the committee that paid, “Committee to Elect Dana Brooks”, and that is the client. The same line also gives a second answer. A separate function peels the committee’s wrapper off to find the candidate it works for: a leading “Committee to Elect”, “Re-Elect”, or “Friends of”, a trailing “for Congress” or “for Mayor”, a party suffix like “(R)”, a year. If what remains contains a word like “party”, “PAC”, or “coalition”, it’s an organization, not a person, and the rules look for the candidate in the body instead.
So one line feeds two fields. (Congressional mail with official-funds wording skips both and reads the candidate from the body instead.)
flowchart TD
line["“Paid for by …” line"] --> clean["Clean: first line only, no address,<br/>no treasurer, no disclaimer sentence"]
clean --> peel["<b>Candidate</b><br/>peel the wrapper:<br/>“Dana Brooks”"]
clean --> norm["<b>Client</b><br/>normalize casing,<br/>punctuation, “The”"]
norm --> override{"In the<br/>overrides<br/>table?"}
override -- Y --> fixed["Hand-corrected<br/>name"]
override -- N --> rules["Rules for party names,<br/>offices, committees"]
fixed --> merge["Compare with the<br/>client Claude names"]
rules --> merge
merge --> client["Client shown<br/>and searched"]
Normalizing matters because the same committee prints its name many ways. In April’s snapshot of the archive, the cleaned disclaimer lines still came to 2,804 distinct names. Hand corrections live in an overrides table keyed by the cleaned name, and every piece is checked against it when it’s processed, so one correction applies to every later piece from that committee. Names without an override go through programmatic rules that normalize party names, strip office suffixes, and tidy committee wording.
The last step compares the rules’ client with the one Claude names in its analysis. If the two are more than 85% similar by string matching, the rules’ spelling wins, because a name copied from the disclaimer is more reliable than one paraphrased in a summary. Otherwise Claude’s answer wins when it is high confidence or when the rules found nothing. For the example, a summary that says “a mailer supporting Dana Brooks for Congress” makes the client “Dana Brooks”; without one, the client stays “Committee To Elect Dana Brooks”.
States: five tiers, strongest first
State is a cascade. Each tier is tried in order, and the first one that answers decides, with a confidence that drops as the evidence gets weaker:
| Tier | Evidence | Confidence |
|---|---|---|
| 0 | A district or city pattern unique to one state, like Columbus, OH | High |
| 1 | A ZIP code in the disclaimer’s address: , OH 45402 |
High |
| 2 | A state’s name spelled out in that address | Medium |
| 3 | Race wording anywhere: “for Texas”, “Texas Senate” | Medium |
| 4 | The state the piece mentions most, at least twice | Low |
Tier 1 does most of the work, and its regex is short:
STATE_ABBR_REGEX = re.compile(r',\s*([A-Z]{2})\s+\d{5}')
The trick is where it looks. A ZIP code anywhere in the piece could be a polling place or an opponent’s office, so tiers 1 and 2 search only the address block after “Paid for by”. That block ends at the first blank line, but never sooner than 150 characters or later than 600. Without that floor, a stray blank line right after the disclaimer closed the window before the ZIP code was reached.
On the labeled set, the state was right on 41 of 50 pieces.
Measuring it
The 50 labeled pieces were the test bench. I ran five rounds of changes against them, and kept each round’s predictions:
| Round | Year right | State right |
|---|---|---|
| 1 | 31 of 33 | 43 of 50 |
| 2 | 31 of 33 | 43 of 50 |
| 3 | 31 of 33 | 42 of 50 |
| 4 | 31 of 33 | 39 of 50 |
| 5 | 31 of 33 | 41 of 50 |
The year never moved. The state got worse: rounds 3 through 5 went into the client rules, and a few of those changes nudged state answers the wrong way. The round that shipped is the fifth, which gets two fewer states right than the first. A labeled set only helps if you look at every column after every change, not just the one you were working on.
The client column has a harder problem: I never scored it like for like. My labels record the paying client, which is often a party or a PAC. The rules’ candidate field records a person. Comparing the two would say more about the mismatch than about the rules.
What’s still wrong
Four things, all in code I wrote:
- Every piece is flagged for review. The rules give each piece a confidence score: date high is worth 2, client high 3, state high 2, and anything under 5 of the possible 7 goes to the review queue. The check also flags a piece with no client or no state. In production, the code that calls it passes only the three confidence levels, not the client and state themselves, so the check always sees them as missing. My test harness passes everything and flags 16 of the 50 labeled pieces. Production flags all of them.
- A client from the disclaimer is always high confidence. The rules confirm a name by checking that its first name appears in the piece. But the name was cut from the disclaimer, which is in the piece, so the check can’t fail.
- “Vote for John Smith” isn’t recognized, because the pattern expects “Vote John Smith”. In capitals, “VOTE FOR JOHN SMITH” comes out as “For John Smith”, at high confidence.
- The client’s confidence belongs to the candidate. The confidence stored with the client is the one for the peeled candidate name, while the client shown and searched may be the committee, a hand correction, or Claude’s answer. The review page, the review flag, and the embedding all read it as the client’s.
The core idea
Regular expressions have a bad reputation for reading natural language, and they deserve it. They break on every new phrasing. This works anyway because the rules read text whose format someone else enforces. The law decides that a disclaimer starts with “Paid for by” and names a committee with an address. InDesign decides that the export stamp is a file name, a page number, a date, and a time. Persuasive copy changes with every piece; fine print doesn’t.
The best signal in a document is often the part nobody was meant to read.