Analysis
Analysis fetches a reference’s bytes, extracts what it can, and writes the result onto the reference. Originals are not modified.
app/analyzers/
Dispatch
Section titled “Dispatch”Analyzer.for(item) returns the first analyzer whose handles? claims the item. Order is the
literal order of Analyzer.all in app/analyzers/analyzer.rb:
Pdf · Image · Doc · Xlsx · Calendar · Pkpass · Email · Feed · Contact · Data · Text · FallbackMost handles? implementations test item.kind. Analyzer::Fallback returns true
unconditionally and is last, so every item has an analyzer.
What each one extracts
Section titled “What each one extracts”| Analyzer | Steps | Shells out to |
|---|---|---|
Pdf |
info, text |
pdfinfo, pdftotext |
Image |
dimensions, deviation, ocr |
vipsheader, vips, tesseract |
Email |
headers, attachments, text |
— (mail gem) |
brew bundle installs the binaries. Analyzer::Base::MAX_TEXT truncates extracted text at 200,000
characters.
The step machine
Section titled “The step machine”step(name) { … } writes one entry per step under reference.analysis["steps"]:
{ "steps": { "text": { "started_at": "2026-09-04T10:00:00.000Z", "finished_at": "2026-09-04T10:00:04.120Z", "result": "…" } }}A step that raises stores error instead of result, with the exception class and a message
truncated to 500 characters, and re-raises.
Each step is written in its own transaction, so a long OCR run does not hold one open.
A step is re-run when any of these hold:
no result is stored |
it has never succeeded |
force: true |
the caller demanded it |
after: is later than finished_at |
the step was declared downstream of something newer |
reference.changed_at is later than finished_at |
the bytes changed under it |
Otherwise the stored result is returned without running anything, which makes re-analyzing an item cheap and the outer job cursor safe to replay.
step_result(name) reads a stored result without running the block.
Two levels of resumability
Section titled “Two levels of resumability”| Scope | Mechanism | |
|---|---|---|
| across items | which item am I on | job-iteration cursor |
| within an item | which step am I on | analysis["steps"] |
Children
Section titled “Children”An analyzer declaring has_children? returns child bodies from children_of(reference).
Analyzer::Base#extract_children! writes each into the children Resource::Database resource,
keyed <reference_id>/<index>/<filename>, and enqueues an AnalyzeItemJob for each.
The key is deterministic, so re-analysis finds last time’s children rather than making a second set.
An item with children returns from run without analyzing itself until children_ready?. The last
child to finish wakes the parent (AnalyzeItemJob#wake_parent).
Item::DEPTH caps the chain at 4 levels.
Failure
Section titled “Failure”The error class decides the policy, in AnalyzeItemJob:
discard_on Analyzer::Failedretry_on Resource::Failed, wait: :polynomially_longer, attempts: 5| Is | Policy | |
|---|---|---|
Analyzer::Failed |
a file that cannot be read | discarded — a retry produces the same result |
Resource::Failed |
a resource that cannot be reached | retried with backoff |
Either way the error is stored under analysis["steps"] on the reference, so it is searchable and
re-runnable.
Summaries
Section titled “Summaries”A summary is a step rather than a thirteenth analyzer, because Analyzer.for is first-match over a
fixed array and nothing else could claim a PDF once Analyzer::Pdf has.
Base#run makes two guarded attempts per reference: analyze, then summarize!. They are separate
so that extraction failing does not skip a summary buildable from the other steps, and a summary
failing never retracts extraction.
summary_prompt has a working default over the text step, which ten of the twelve analyzers
write, so most get a summary without declaring anything. A body shorter than
Base::SUMMARY_MINIMUM, 200 characters, gets none. Email, Calendar and Xlsx override the
prompt to ask in their own terms — an email’s prompt carries its headers and its attachments’
summaries, so a message is described using what was inside the PDF stapled to it.
Only summary and keywords survive the answer, and keywords are capped at
Base::SUMMARY_KEYWORDS, 20. Keywords arriving as "a, b, c" rather than an array are coerced,
because small models do that.
An analyzer declares summary_role:
| Role | Declared by |
|---|---|
:smart |
the default, on Analyzer::Base |
:fast |
Email |
:vision |
Image |
A role resolves through Resource.for_role, which finds an active inference resource whose
details["models"] names it, preferring the tenant’s default. Roles span servers: fast can be one
host and smart another, and re-pointing a role is data rather than a deploy.
If no resource serves the role, summarize! returns before touching step. No key is written,
no error is recorded, and the analysis is byte-identical to one with inference switched off. Writing
result: nil would satisfy the step cache forever, and the summary would never compute once a model
appeared.
Because of that, turning inference on does not fill in a catalog analyzed before it. An after:
cutoff cannot expire a step that was never written, so the backfill is explicit:
AnalyzeItemsJob.perform_later(tenant.id, {}).
Images
Section titled “Images”Analyzer::Image sends a large thumbnail as summary_images alongside its prompt, so the model
describes the picture rather than its filename. OCR text is included in the prompt, fenced and
labelled as data rather than instructions.
An image that is trivial — a sliver 10 pixels or less on a side, or flat with a standard deviation
below 1.0 — skips the model entirely and takes trivial_summary.
The same thumbnail is OCR’s second attempt. tesseract cannot open an animated GIF or a HEIC, both
of which vips reads happily, so a failed tesseract run is retried against the rendered preview
rather than recorded as an error.
A camera raw is normalized before any of that happens. Raw.preview runs simple_dcraw -e to pull
out the JPEG the camera embedded — full size on most bodies — and every step downstream sees that
file instead of the original; a preview narrower than 1024px is discarded for a full -T develop.
Without it a raw is read by whichever loader claims it first, and the answer differs by machine: a
NEF is a TIFF underneath, so the deployed image’s vips reads the 160px thumbnail in its header and
describes that, while a workstation with a newer libvips demosaics the actual photograph. Kind::RAW
is the extension list, taken from the loader in libvips 8.17 that knows the format family.
When a summary is recomputed
Section titled “When a summary is recomputed”step(after:) takes the later of the analyzer’s summary_after, defaulting to
Analyzer::PROMPTS_CHANGED_AT and bumped when a prompt changes, and the inference resource’s
updated_at. So swapping the model re-runs every summary exactly once. This works because
record_check and release_sync! write with update_columns: a health check does not move
updated_at, and so does not re-summarize the catalog.
Provenance
Section titled “Provenance”The resource key, model and role are written to the step entry, beside result rather than inside
it. Reference#extracted collects only result, so a model name in there would land in the
OpenSearch body of every document.
Every call is a prompts row carrying the resource, role, model, request and response. That is
where token accounting goes.
There is one row per attempt, so the JSON retries are visible — but only for failures the analyzer
swallows. Tenant.switch opens a savepoint, and a Resource::Failed escaping run rolls it back:
a timeout or a 5xx discards the prompt rows, the step’s error entry and analyzed_at along with the
rest of the attempt. So a model that answered badly leaves a trail, and a model that could not be
reached leaves the Run and the resource’s check_error instead.
Fairness
Section titled “Fairness”AnalyzeItemJob runs on the analysis queue with its own worker pool, and caps concurrent jobs
per tenant:
limits_concurrency to: ENV.fetch("ANALYSIS_PER_TENANT", 2).to_i, key: ->(tenant_id, _item_id) { "analysis/#{tenant_id}" }, duration: 30.minutesSee Jobs.