From a photo of a business card to a CRM record

OCR gives you text with no structure. A regex pass takes the fields that are certain, a model handles what is genuinely ambiguous, and a confidence score decides what a human still has to look at.

A business card, an extraction step, and a contact record

Take the fields a pattern can prove, hand a model only what is genuinely ambiguous, and attach a confidence score so a human knows what to check. Sending the whole card to a model and asking for JSON works in a demo and produces a contact database nobody trusts.

The gap between those two is where this feature actually lives.

The problem OCR leaves you

OCR returns text and position. It does not return meaning:

Nguyen Van Minh
Head of Partnerships
BRIGHTLANE LOGISTICS
minh.nguyen@brightlane.vn
+84 24 3936 1188
www.brightlane.vn
12 Trang Thi, Hoan Kiem, Hanoi

A person reads that instantly. A program has to decide which line is a name, which is a company, and which is a job title — and those three are frequently indistinguishable without world knowledge. "Head of Partnerships" could be a department. "BRIGHTLANE LOGISTICS" is only obviously a company because of the capitals and the word logistics.

Deterministic first

OCR, then deterministic extraction, then a model for what remains ambiguous

Three of those lines are not ambiguous at all:

const PATTERNS = {
  email: /\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b/,
  url:   /\b(?:https?:\/\/|www\.)[\w-]+(?:\.[\w-]+)+\b/,
  phone: /(?:\+\d{1,3}[\s-]?)?(?:\(?\d{1,4}\)?[\s.-]?){2,5}\d{2,4}/,
}

const found = {}
let rest = ocrText
for (const [field, re] of Object.entries(PATTERNS)) {
  const m = rest.match(re)
  if (m) { found[field] = m[0].trim(); rest = rest.replace(m[0], '') }
}

Run it on the card above:

certain, no model needed:
  email  minh.nguyen@brightlane.vn
  url    www.brightlane.vn
  phone  +84 24 3936 1188

left for the model:
  Nguyen Van Minh
  Head of Partnerships
  BRIGHTLANE LOGISTICS
  12 Trang Thi, Hoan Kiem, Hanoi

Three fields extracted with certainty, and the model's job shrank by half.

This matters for more than cost. A regex cannot hallucinate an email address. Ask a model for the email and you introduce a small, permanent chance of a plausible-looking address that is not on the card — and an email that is wrong in one character fails silently, forever. Determinism where determinism is available is not an optimisation, it is a correctness decision.

Remove each match as you find it. Otherwise the phone pattern happily matches part of the postcode in the address line.

The model gets the hard part, and a tight brief

Four lines left, and now the instruction is narrow:

Here are the remaining lines from a business card, in order:
  Nguyen Van Minh
  Head of Partnerships
  BRIGHTLANE LOGISTICS
  12 Trang Thi, Hoan Kiem, Hanoi

Assign each line to exactly one of: full_name, job_title, company, address, unknown.
Use "unknown" if you are not confident. Do not invent lines. Return JSON only.

Three things make this reliable:

  • Assign, do not generate. The model chooses labels for lines that exist. It never produces field values, so it cannot invent a company that was not on the card.
  • unknown is a valid answer. Without it, a model forced to choose will choose, and a wrong label is worse than an empty one.
  • Position is a real signal. Business cards put the name near the top. Pass the lines in order and say so.

Validate the response against the input before you trust it: every returned line must be one you sent. That check costs nothing and catches the failure mode that matters.

Confidence decides who looks at it

Attach a score to the record, not to your own peace of mind:

const confidence =
  (found.email ? 0.4 : 0) +
  (found.phone ? 0.2 : 0) +
  (labels.full_name && labels.full_name !== 'unknown' ? 0.3 : 0) +
  (labels.company  && labels.company  !== 'unknown' ? 0.1 : 0)

Then let it route:

Score What happens
High saved directly, no interruption
Middle saved, flagged for a glance
Low shown to the user for confirmation before saving

The important design point is that the user confirming a card is not a failure state. It takes them four seconds and it is how the database stays worth having. A product that silently saves a wrong company name is worse than one that occasionally asks.

The parts that will surprise you

  • Photographs, not scans. Real input is taken at an angle, in bad light, sometimes of a card lying on a desk. Deskew and crop before OCR; it changes accuracy more than any prompt.
  • Two-sided cards. Often one side is in English and the other is not, with different information on each. Treat them as one record from two images, not two contacts.
  • Names are not first last. "Nguyen Van Minh" is family name first. Storing first_name and last_name forces a wrong guess; store full_name and add parts only when you are sure.
  • The same person, twice. The second card from someone should update the record, not create another. Match on email first, then phone — the two fields the regex pass gives you with certainty, which is another reason to extract them deterministically.

Check it yourself

The deterministic pass, on your own machine:

node extract.mjs

with the PATTERNS block above and any card text you like. Try adding a postcode to the address line and watch the phone regex claim it — then move the rest.replace before the address is considered, and watch it stop. That interaction is most of the work in a real extractor.

Where this goes next

This is the capture path in Simple CRM — the point being that a contact you never typed is worth more than a CRM field that is technically complete. Once the record exists, the follow-up scheduling runs on the same job queue as everything else.

Related: running user expressions safely and the flow builder data model.