Skip to content

Analysis

Analysis fetches a reference’s bytes, extracts what it can, and writes the result onto the reference. Originals are not modified.

app/analyzers/

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 · Fallback

Most handles? implementations test item.kind. Analyzer::Fallback returns true unconditionally and is last, so every item has an analyzer.

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.

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.

Scope Mechanism
across items which item am I on job-iteration cursor
within an item which step am I on analysis["steps"]

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.

The error class decides the policy, in AnalyzeItemJob:

discard_on Analyzer::Failed
retry_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.

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, {}).

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.

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.

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.

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.minutes

See Jobs.