Skip to main content
You have scanned vaccination records with no text layer, and you want Jev to check that the information on each one is correct before a trip. Jev takes the image and answers a set list of questions about it:
  • a Noul question per vaccine: is it on the record?
  • a Box question per vaccine: where is its row?
  • a Choice question per row: what year does the date say, or is it unreadable?
Jev reading a scanned travel vaccination record and flagging two
rows In this example, Jev reads the travel vaccination record above into one row per vaccine, and flags the rows that need review before the trip. This record has two:
  • The rabies date is under a batch sticker, so it can’t be read from the scan. Jev answers unreadable instead of guessing.
  • The typhoid dose is from June 2021. The date is clear, but a typhoid injection protects for about three years, so it won’t cover the trip in November 2026.
In this case we already know the possible answers: the vaccines the clinic gives, and the years a dose could have been given in. We also have unreadable as an option to account for values that cannot be found on the page. Two requests read the scan, and a third checks the readings as text. Your code decides what goes into each request and which rows to flag.

Setup

Set TYPESAFE_API_KEY. Download vaccination_record.jpg and json_cache.json from typesafe-public-examples and keep them in one folder with the script. json_cache.json stores the API responses behind the numbers on this page. While it’s there, the script reuses those responses instead of calling the API, so you get the same numbers. Delete it to call the API yourself.
Images and Box questions are an early preview. Requests that carry an image or a Box question need the TypeSafe-Danger-Zone-Images: true header, and the request format can change between releases. See Images.

Send the scan to Jev

A scanned travel vaccination record Each request carries the scan in its state as Image(PAGE), under the name page, and each question mentions page to say which image it is about. All the questions in a request see the same image, so each step’s questions go in one request. Jev accepts PNG and JPEG files up to 16 MiB. Each side has to be between 280 and 1568 pixels, so scale a high-resolution scan down before sending it. The image limits page lists the rest, including limits on the whole request.

Find which vaccines are on the record

Jev doesn’t extract data into a list. It answers questions about what’s on the page, so you ask it about each thing you’re looking for. In this case, that’s the 16 vaccines the clinic gives. The first request asks two questions about each one:
  • a Noul question: is this vaccine in the table?
  • a Box question: where is its row?
The list includes vaccines with similar names, like the combined hepatitis A and B vaccine next to Hepatitis A, to check that Jev matches the exact name. One more Noul question asks whether the table has a vaccine that isn’t on the list at all, since none of the other questions would catch that.
All the questions go in one request. Vaccines that score 0.5 or more count as on the page, and the top edges of their boxes give the page order.
The six vaccines on the page score 0.97 or more, and the look-alikes score 0.02 or less. Sorted by their boxes, the six rows come out in the same order as the page. The question about an unlisted vaccine scores 0.02, so there isn’t one.
A Box always returns a box, even for a vaccine that isn’t on the page. Only use the box when the Noul says the vaccine is there.
Sixteen vaccines make 33 questions. With a list of thousands, the questions may not fit in one request (see context length). A bigger version of this would narrow the list first, for example with one Noul question per group of vaccines.

Read the year of each dose

The in-date check only needs to know roughly how old each dose is, so the second request asks for the year alone, with one Choice question per row. A Choice always picks one of its options, so the date under the sticker would otherwise get whichever year looked closest. The list of years ends with other and unreadable, which lets Jev say it can’t read the date. Each row also gets a Noul question asking whether the date is fully legible. It’s a second signal, separate from the Choice, that something is covering the date.
The record writes dates as day/month/year, so the year question says the year is the last part.
An other or unreadable pick leaves the row without a year, so there’s nothing to check yet.
Five years read correctly. For rabies, the Choice picks unreadable and the legibility Noul is 0.05. The legibility Noul scores the clear dates between 0.86 and 0.96, lower than the Choice answers. That’s why it gets its own cutoff of 0.50 further down. The lowest of them, at 0.86, is the Hepatitis B row with a highlighter stroke across it, still far above the 0.05 for the date under the sticker.

Check whether each dose covers the trip

Reading the page correctly doesn’t catch the typhoid dose. Its year is clear, and Jev read it. The third request asks a different question: will this dose still protect the traveller on the day they leave? This request doesn’t include the image. It sends the readings as text, so the answer is only about the dose, and the reading stays with the image questions. We work out roughly how long before departure each dose was given, because date arithmetic is a job for code, and leave out rows without a year. The state gives Jev the departure date, and the criteria say what counts as in date.
This request has no image, so it doesn’t need the images header.
Typhoid, given about 5 years before departure, scores 0.38. The other doses score 0.83 or more. Rabies has no score because its year couldn’t be read.

Flag the rows to check

A row gets up to two flags:
  • Check the reading if an answer about finding or reading the row is below 0.90, the legibility Noul is below 0.50, or the year came back other or unreadable.
  • Check the dose if the in-date score is below 0.50.
The whole page is flagged if the question about an unlisted vaccine scores 0.5 or more, since that vaccine has no row of its own. A high in-date score doesn’t cancel a doubtful reading, because it was judged on a year that might be wrong. The cutoffs (READ_CUTOFF at 0.90, LEGIBLE_CUTOFF and IN_DATE_CUTOFF at 0.50) are a starting point. Tune them on records a person has already checked.

Crop the flagged rows for the nurse

A Box answer has top, bottom, left and right edges as fractions of the image’s height and width, plus a confidence. Multiply by the image size to get pixels. The crops reuse the boxes from the first request with a small margin, and a wider margin when the box’s confidence is below 0.6.
output Typhoid is flagged to check the dose, and Rabies to check the reading. Each crop shows the whole row.

Using it on your own documents

This approach fits any document where you know the possible answers in advance. Swap in your own lists for VACCINES and YEAR, keep other and unreadable in each list of options, and rewrite the in-date question for your documents.

Appendix

Why reading and checking are separate requests

Skip the text-only request and ask the in-date question about the scan itself, with the departure date in the header.
Rabies scores 0.88 and passes, with its date under a sticker. Typhoid scores 0.50, right on the cutoff, so it passes too. Both misses come from one answer covering two steps: you can’t tell whether Jev failed to read the date or judged the dose wrongly.