Ingest enqueues no jobs, so OCR and AI analysis never run on their own #144
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: MEDIUM — two implemented pipelines that nothing starts
Found while probing #105.
docs/circa-spec.md§8.3 lists the ingest steps. Two of them are not performed:backend/app/services/ingest.pycontains no call toenqueueat all. Both handlersexist and work —
app/workers/handlers/ocr.py(#4) andapp/workers/handlers/ai_analysis.py(#3) are implemented, registered and tested. Nothing starts them.
Today the only way OCR runs is a reviewer pressing the button behind
POST /api/photos/{id}/evidence/ocr-rerun, one photograph at a time. So on a freshlyingested archive, the back of every print — where a date is most often written down,
which is the entire reason backs are scanned at all — carries no evidence until somebody
manually asks for it on that specific photograph. The signal the scanning of backs exists
to capture is collected only on request.
This matters more after #105 than before it. Bulk ingest makes it realistic to put
thousands of scans in at once, and #105 pairs backs to their fronts automatically — so
the archive will now routinely hold a back scan that has never been read.
Why it was not fixed in #105
It is a separate gap with its own design questions, and #105 was already carrying a
pairing model, a fold, and an ingest ledger. Attaching a back to an existing photograph
in #105 deliberately matches what ingest does today — which is to enqueue nothing — so
that pairing did not quietly change job behaviour as a side effect.
The questions that need answering
ingest_photo, deliberately(#93 — the worker is not guaranteed to be running, and a missing thumbnail is a visible
hole). OCR is a much heavier operation and the same argument does not obviously carry.
5,000 files in one run. Whether that should produce 5,000 queued OCR jobs, or whether
bulk ingest should stage them differently, is a real decision.
natural place to enqueue OCR for a back that has just arrived, and currently they do
not — matching ingest.
scoped here or split out.
References
backend/app/services/ingest.py— noenqueuecall anywherebackend/app/workers/handlers/ocr.py(#4),backend/app/workers/handlers/ai_analysis.py(#3)docs/circa-spec.md§8.3 steps 5 and 9Picking this up, after v0.3.0.
Probe confirms the body, and adds one thing that changes the design.
app/services/ingest.pystill contains noenqueuecall of any kind; the only two callers in the whole application areenqueue_ai_rerunandenqueue_ocr_reruninapp/api/routes/jobs.py, both driven by a reviewer pressing a button on one photograph.The two halves of §8.3 must not be treated the same way, because one of them spends money.
ocris local.ocr_backend: "auto"uses tesseract when it is installed and refuses cleanly when it is not, so it costs CPU and nothing else.ai_analysisis a paid API call, and the failure mode under bulk ingest is worse than "expensive":docs/circa-spec.md§14 estimates ~$30 for a full pass over 10,000 photos, which is ~$0.003 a photograph.ai_daily_budget_usd: 5.00andai_monthly_budget_usd: 30.00, checked before every call.PermanentJobError(app/workers/handlers/ai_analysis.py:102-104). Permanent means it does not retry.So auto-enqueueing AI at ingest on a 5,000-scan import would spend the daily ceiling after roughly 1,650 photographs and then permanently fail the remaining ~3,350 jobs, each of which would need a manual rerun to recover. Not deferred, not throttled — failed. And that is the good case; with the ceilings raised it would quietly spend the entire monthly budget as a side effect of importing a box of photographs, which is not a thing importing photographs should do.
The ceiling behaviour is right for what it was designed for — a reviewer pressing rerun. It is the wrong shape for an automatic per-ingest trigger, and that is a property of the trigger, not a bug in the ceiling.
So this issue is really two decisions, and only one of them is easy. OCR should run automatically: it is free, it is local, and reading the back of a print is the entire reason backs are scanned. What ingest should do about AI is a real question, and I am putting it to @rbrooks rather than reading §8.3 step 9 literally and spending his money on the strength of a spec line written before the cost model existed.
Also settled by the probe, and worth writing down: OCR should be enqueued rather than run inline. #93's argument for generating derivatives inline was that a missing thumbnail is a visible hole in the grid — a camera emoji where a photograph should be. A missing OCR result is not that; it is simply evidence that has not arrived yet, which the evidence panel already represents honestly. OCR is also far heavier than a thumbnail, and ingest already holds SQLite's write lock through a ~300 ms derivative per file.
Where the enqueue points are, after #105. A back scan can now reach a photograph three ways, and OCR should follow the back rather than the row: an ingest that carries a back, an attach (a back arriving for a front already in the archive), and an orphan back (a back kept because its front has not arrived). A fold needs no enqueue — the back was already there and was queued when it arrived, and re-queuing would read the same handwriting twice.
Done in
4f07d67.What landed
OCR follows the back scan, not the photo row. After #105 a back can reach a photograph three ways, and each queues one reading: an ingest carrying its own back, an attach onto a front already here, and an orphan back whose front has not turned up. A fourth falls out of the implementation and is right — an ambiguous back, refused a pairing because two fronts matched, is a row whose only image is a back scan, and the case where the archive is least sure what it holds is a poor one to gather no evidence on.
A fold queues nothing, and that is the case worth naming. The back was already there and was read when it arrived; a second reading would recognise the same handwriting again and append a row saying what the first already says.
That falls out of a guard derived from the variable that decided where the bytes went —
key_back is not None and not is_duplicate— rather than a list of outcomes free to fall out of step the next time a path is added. Which is precisely how #105's dry run came to predict folds the real run would not perform, so it seemed worth not doing twice.AI is not queued, per @rbrooks. Knowing deviation from §8.3 step 9, recorded in the code where someone holding the spec would otherwise "fix" it. The numbers are in my earlier comment; the short version is that the ceiling is right for what it was built for and it was the trigger that was wrong.
The enqueue is one definition rather than two. Attempt numbering, the idempotency key, the in-flight fast path and the race recovery lived inside
_enqueue_rerun; ingest needed all of it. Nowapp/services/jobs.py, called by both.One line had to change in the move and it is the one that mattered: the
IntegrityErrorrecovery is a savepoint, notdb.rollback(). On the route that rollback discarded a transaction holding nothing but the failed insert. Insideingest_photoit would have discarded the photograph, its evidence, its album and the ledger row recording that the file ever arrived — losing a scan because a job could not be queued for it. There is also adb.flush()before the savepoint opens, so whatever the caller has pending lands outside it; that detail was not in my spec and is the better call.Verified rather than assumed
Run against a real worker, not only in tests, and with the queue drained between batches so the in-flight fast path could not mask the result:
ocrjob, against the front the back attached to, and noai_analysisjob;That last one matters more than it looks. Three of the new tests were vacuous on first write — they passed off
enqueue_photo_job's in-flight fast path rather than off the guard, because a second enqueue in the same session adds no row. Rebuilding them around a drained queue surfaced a real behavioural fact rather than only a test one: without thekey_backguard, a fold in a later batch would queue a second reading. The fast path only hides that within one session, and §8.2 makes later batches the normal case.Fifteen mutations, each breaking exactly one behaviour, all caught — including one that adds the
ai_analysisenqueue back per §8.3 step 9, which exists to pin the deviation, because a future reader with the spec in hand is exactly who would re-add it.What I could not verify
Text recognition itself was never exercised live. Neither this machine nor the Linux dev server has tesseract installed, so
get_ocr_backend()returnsDisabledOcron both. What I confirmed is the chain up to it — ingest queues, worker claims, handler runs — failing with the correct and actionable message:The recognition is #4's code and
test_ocr_handler.pycovers it against a stub. But "tesseract reads a real back scan and the evidence lands" remains unobserved on any machine, and it is worth someone running once on a host that has the package before trusting a bulk import to it.Not done, deliberately
The rest of §8.3 step 9 — near-duplicate detection and constraint propagation — is untouched. Both are their own work with their own design questions, and neither is blocked by this. Say the word and I will file them; I have not, to avoid manufacturing issues nobody asked for.
Queue depth is the one cost I would flag. A 5,000-back import leaves 5,000 rows the Jobs page paginates through. That is a UI question rather than an ingest one, and it does not need solving before a first real import — but it will be visible on one.
Correction to my previous comment: the gap is closed. Real text recognition now verified end to end.
I said recognition had never been exercised live because neither machine has tesseract. @rbrooks then noted Circa is not deployed anywhere at all — so there is no host to run it on and never was, which makes "run it once on a machine with the package" a recommendation with nowhere to land. So I built the machine instead: the e2e image is Ubuntu-based and its venv already holds the backend dependencies, so
apt-get install tesseract-ocron top of it gives a complete environment.The whole chain, with tesseract 5.3.4 actually reading a print:
A front scan and a back scan carrying real rendered text —
Easter 1962andAunt Mary's garden, the second deliberately included as a line with no date in it.Nobody asked for that. The scan was imported and the handwriting on the reverse became dated evidence on its own, which is the entire point of this issue.
Four things worth noting from it, none of which the test suite would have shown:
Easter 1962became1962-01-01..1962-12-31— year precision, not a guessed Easter date. The parser did not invent a day it could not know.reliability=low, correctly. Spec §10.1 rates a handwritten date Low because it may simply be wrong — written years later, from memory. Underprojections.foldthat means this does not promote the photograph toneeds_reviewon its own, which is the intended reading: a machine's guess at someone's handwriting is not a reason to tell a reviewer the photograph is ready to date.redbk42_001.jpg), so there was nothing to duplicate — but it confirms the OCR row lands on the same photograph the back attached to rather than a row of its own.So the only claim I could not stand behind is now observed. What remains genuinely unverified is scale: this was two files, and nothing here has ever ingested a real collection.