Skip to content

Cookbook: ID cards

The job: turn a photo or scan of an identity document into a typed record. IDs have a hard constraint that invoices do not — the data is PII, so the redaction story matters from the first line.

from scriva import Orchestrator, Worker
from scriva.prompts import Prompt
worker = (
Worker.openai("gpt-4o")
.prompt(Prompt.id_card)
.score(method="rendering")
.retry(times=3)
)
recipe = (
Orchestrator()
.orient()
.deskew()
.crop(margins="auto")
.split.boxes(detector="id-card") # photo, fields, MRZ band
.classify()
.recognize.by_kind({
"text": worker,
"number": Worker.number(),
"date": Worker.date(),
"address": Worker.address(),
"blank": Worker.skip(),
})
.reconstruct.kv()
.export.json("card.json")
)
card = recipe("licence.jpg").to_dict()["fields"]
print(card["full_name"], card["date_of_birth"], card["expiry_date"])

For passports, swap the splitter and validate the MRZ in reconstruct:

passport_recipe = (
Orchestrator()
.orient()
.deskew()
.split.boxes(detector="passport") # photo + visual zone + MRZ
.recognize(worker)
.reconstruct.passport(validate_mrz=True) # raises on MRZ disagreement
.export.json("passport.json")
)

.reconstruct.passport(validate_mrz=True) runs the standard ICAO check-digit validation and raises RecognitionError rather than silently returning a corrupt record.

When you only need MRZ (faster, more deterministic, no VLM cost):

from scriva.schemas import Passport
passport = Passport.from_mrz("""
P<USAMARTIN<<JONATHAN<<<<<<<<<<<<<<<<<<<<<<<<
1234567894USA8501231M3001011<<<<<<<<<<<<<<04
""")

Passport.from_mrz runs the standard ICAO validation and raises MRZParseError on malformed input. No network required.

For a hybrid path — VLM reads the MRZ from the image, parser validates it:

mrz_only = (
Orchestrator()
.split.boxes(detector="mrz-band") # bottom 2 lines only
.recognize(Worker.openai("gpt-4o").prompt(Prompt.mrz))
.reconstruct.document()
)
text = mrz_only("passport.jpg").render()
passport = Passport.from_mrz(text)

split.boxes(detector="mrz-band") knows where the MRZ sits on a TD3 passport; the Worker just transcribes those two lines.

ID images carry PII (face, signature, sometimes biometric markers). Strip what you do not need before shipping the crop to a third-party Worker:

from scriva.reliability import with_redaction, RedactionPolicy
worker = with_redaction(
Worker.openai("gpt-4o").prompt(Prompt.id_card),
policy=RedactionPolicy(
mask_regions=["face", "signature"], # built-in region detectors
patterns=[r"\b\d{9}\b"], # raw 9-digit IDs in hint text
replace_with="<REDACTED>",
scan_image=True,
),
audit_log=Path("audit/id-redactions.jsonl"),
)

with_redaction(...) wraps a Worker; redaction runs before the crop is sent upstream, so the third party never sees the face.

The audit log entry includes the source path, the redacted region IDs, and an ISO timestamp — enough to demonstrate compliance after the fact without retaining the redacted content itself.

Worker.address() returns a structured address; the reconstruct step slots its fields into the typed record:

card["address"]["line_1"] # "123 Main St"
card["address"]["locality"] # "Springfield"
card["address"]["region"] # "IL"
card["address"]["postal_code"] # "62704"
card["address"]["country"] # "US"

To bypass normalisation (e.g. when legal-name parity is required), substitute Worker.text() for the address kind:

recipe = recipe.recognize.by_kind({"address": Worker.text(), ...})

For age-verification flows, you usually do not need the whole document back — just a boolean and a confidence score:

from datetime import date, timedelta
result = recipe("licence.jpg")
card = result.to_dict()["fields"]
is_18_plus = card["date_of_birth"] <= date.today() - timedelta(days=365 * 18)
confidence = result.confidence_for("date_of_birth")

The pattern: extract, then derive. The recipe does not encode the gate itself, because the gate is your business logic, not the document’s.

import scriva
scriva.config.set_log_redactor(scriva.reliability.RedactionPolicy.identity())

Once set, every log record (including event payloads) runs through the redactor. The Worker still sees the unredacted crop — this only sanitises observability output. Pair with with_redaction(...) when you also want to keep the VLM provider from seeing the data.

worker = Worker.openai("gpt-4o").prompt(Prompt.id_card) # no .cache(...)

For ID flows, disable caching unless you have reasoned about it explicitly. A semantic cache hit on a photo of a different person’s licence that looks similar enough is the classic PII leak — much better to pay the VLM cost than to risk crossing identities.

If you must cache (e.g. retry-safety inside a single submission), scope the cache by tenant + session:

from scriva.cache import Cache
worker = worker.cache(Cache.fs(f".cache/{tenant}/{session_id}", ttl_s=60 * 10))

ID extraction is the textbook case for “extract, validate, then delete”:

result = recipe(source)
publish_to_kyc(result.to_dict()["fields"])
source.unlink() # delete the source image

Schedule a daily sweep of any sidecar .json outputs that hold raw PII.

A wrong DOB on a KYC flow is much worse than a re-prompt to the user:

result = recipe("licence.jpg")
fields = ("full_name", "date_of_birth", "id_number")
if min(result.confidence_for(f) or 0 for f in fields) < 0.9:
request_better_photo()
else:
accept(result.to_dict()["fields"])

For the same gate inside the recipe, use .review.hitl(...):

recipe = recipe.review.hitl(
when=lambda r: r.region.attrs.get("field") in {"full_name", "date_of_birth"}
and (r.confidence or 0) < 0.9,
sidecar="kyc-review.json",
)