space ocr update #2
It was a week of refactoring: one response shape across every surface, the structure behind it pulled into one place, and the bugs that turned up while I was in there.
The shape. /ocr/fields, /ocr/markdown and /ocr/text now return the same four keys. values is your data, exactly the schema you asked for and nothing else. cells is a flat map keyed by path, one entry per value, carrying box and quad (both always present, normalized 0–1000), verified, review, and the raw evidence behind that verdict. review is the summary: declared, returned, boxed, verified, flagged[], by_reason. image is the source size, for turning the normalized boxes back into pixels. Full shape with a worked example: https://space-ocr.com/docs#post-ocr-fields
The reason is that the old shape put my keys and your keys in the same object. Every value you asked for sat at the top level next to field_bboxes, review_summary, image_size, so knowing which half was yours meant knowing a reserved list that grew every time I shipped something — and twice it grew without me noticing, which is a bug I would rather not be able to write again. It also carried counters whose unit was never defined: needs_review: 2 never said whether it counted cells or reasons, and I could not answer it without reading my own code.
Both problems go away when the data is in one place and the diagnostics are in another. The review count is now the length of flagged, so there is nothing left to keep in sync.
Mapping, if you were reading the old one: values move under values, field_bboxes[f] becomes cells[f].box (with quad beside it), review_summary becomes review, image_size becomes image. Paths in cells use the same grammar as flagged[].path, so a flagged path like items[0].price is a lookup and never a search. Sheet rows, jobs, webhook payloads and upload?wait all speak it too.
The decision worth explaining is where the conversion happens. I did not migrate what is stored. The new shape is built at the serving boundary by one pure function with its own tests, and stored results are converted as they are read — which means results from before the change come back in the new shape as well, and I never had to run a migration over anyone's data to get there. The web app went through the same door: the readers that draw boxes, flags and crops used to each know the response format, and now they share one module.
The rest, found while refactoring:
GET /view now sorts sheet rows the way /edit addresses them. They could disagree, which meant the row index you read was not always the row you wrote to. This one was a real bug and the reason I went looking. https://space-ocr.com/docs#get-view
An image I cannot read comes back as 400 invalid_image instead of 502. It is not charged and retrying will not change it, so 502 now means my side and is still worth a retry. https://space-ocr.com/docs#errors
A field can name the label printed next to it. When the same value appears twice on a page, the coordinates anchor to the occurrence beside that label. Like required, it is never shown to the model — the text does not change, only where the box lands. Sheet columns take it too. https://space-ocr.com/docs#post-create
The reference now documents FieldSpec and ColumnSpec property by property, including the rule that values come back exactly as printed. templateId is gone from it.
You can run a page on the site without signing up, and there is no longer a cap on that.
If you are storing results, store values whole and keep cells beside it rather than flattening the two back together. The whole point of the split is that your half never has to make room for mine again.
Replies
“A bug I would rather not be able to write again" is probably my favorite part of this whole refactor😄
@yonghahwang Really like the thinking behind separating values from the diagnostics. The serving boundary conversion is especially clean it fixes the API shape without forcing users into a data migration. Small detail, but the normalized coordinates + direct path lookup should make debugging OCR results much easier too.
@joseph_walker2 Thank you for reading it. And yes, exactly the point I was going for — what I kept coming back to was which response would actually be practical and convenient to work with.
values and cells now have to agree about which paths exist, so that's two structures where there used to be one. Is there a check that fails when a path shows up in one and not the other, or is it true by construction? Invariants like that hold right up until someone adds a write path that only touches one side.
Separately, removing the cap on running a page without signing up is the change I'd have shipped last and I think it's the one that moves the most. Anyone evaluating an OCR API has a bad photo in their downloads folder that will break it, and letting them find that out in ten seconds beats anything the docs can say.