Engineering

Building OnisAI

OnisAI is an operations platform for rideshare drivers: it ingests an offer as an image, parses it, scores it against a model of what the job actually pays, and carries it through its lifecycle with a full audit trail. Built with a cross-functional team; this is an account of how it works and why it was built this way, focused on the parts I worked on.

Role
Software Developer & AI Automation
Engagement
OnisAI · Contract · Remote
Period
Oct 2025 – Jun 2026 · 9 months
Contribution
OCR pipeline, scoring engine, backend components, dashboard UI
The problem Architecture Contextual banding OCR degradation Event sourcing Closing the loop Export queue Multi-tenancy

The problem

A rideshare offer arrives as a countdown. The screen shows a fare, a trip distance and a pickup distance. It does not show the one number that determines whether the job is worth taking: what it pays per hour, after the unpaid miles driven to reach the passenger and the fixed overhead every job carries.

Drivers compensate with rules of thumb — a minimum fare, a maximum pickup distance. Both are wrong in opposite directions. A flat pickup limit rejects excellent long trips and accepts terrible short ones, because it treats a mile of deadhead as costing the same regardless of what it buys.

The core insight the product is built on: pickup cost is only meaningful relative to the trip it purchases. Everything else follows from that.

Architecture

Five stages, each independently testable, with the scoring engine deliberately isolated as a pure function of parsed fields.

StageModuleResponsibility
Ingestserver.jsMultipart upload from an iOS Shortcut, one endpoint, no session needed on the capture path
OCRocr.jsProvider abstraction over Tesseract and Google Cloud Vision, selected by env var
Parseparser.jsRegex-driven field extraction from noisy OCR text, with overlay rejection
Scorescoring.jsPure function: parsed fields in, nine metrics and two verdicts out
Lifecycledb.jsState machine over trips, with an append-only event log

Keeping scoring.js free of I/O is what makes the public demo on this site possible at all — the same module runs unmodified in a browser, which is also the cheapest possible proof that the scoring is genuinely deterministic.

Contextual pickup banding

The decision SCORING

The naive implementation is a constant: reject any pickup over N miles. It was rejected because it produces exactly the wrong behaviour at both ends of the range.

Instead the engine classifies the trip into one of three bands first, then judges the pickup against that band's thresholds. A 2.4-mile pickup is TOO FAR for a five-minute hop and CLOSE for a twenty-seven-minute run — and the engine says so.

BandTriggerCLOSESLIGHTLY FAR
Short< 5 mi and < 10 min≤ 1.0 mi and ≤ 5 min≤ 1.5 mi or ≤ 8 min
Medium≥ 5 mi or ≥ 10 min≤ 1.5 mi and ≤ 8 min≤ 2.0 mi or ≤ 10 min
Long≥ 10 mi or ≥ 25 min≤ 2.5 mi and ≤ 10 min≤ 3.5 mi or ≤ 14 min

Note the asymmetry in the operators: CLOSE requires both distance and time to be within bounds, while SLIGHTLY FAR accepts either. That is not an oversight. Distance and time diverge under traffic, and a pickup that is short in miles but long in minutes is a traffic problem, not a distance problem — it should degrade to a warning, not pass as clean.

Overhead-adjusted hourly SCORING

Nominal hourly rate treats a job as ending the instant the passenger steps out. In reality every job costs a couple of minutes of waiting, loading and repositioning, and that overhead is proportionally brutal on short trips. Charging it before judging the rate is what stops the engine from over-rewarding quick hops.

hourly_adj = price / max(trip_min + overheadMinutes, 1) × 60

The overhead and both thresholds are environment-configurable (OVERHEAD_MINUTES, GOOD_HOURLY_MIN, BAD_HOURLY_MAX) rather than hard-coded, because the right numbers differ by city, vehicle and driver. The defaults — 2 minutes, £28, £22 — are starting points, not truths.

OCR that degrades instead of crashing

Two OCR providers are supported: Tesseract, which runs locally and free, and Google Cloud Vision, which is markedly more accurate on low-contrast phone screenshots and costs money. The choice is a deployment concern, not a code concern, so it is an environment variable.

The more important decision is the failure mode. Cloud Vision is an optional dependency: the module attempts to require it lazily, caches the load error if it is absent, and returns a structured failure result rather than throwing.

A missing OCR provider degrades one request. It does not take down the process, and it does not lose the driver's screenshot.

That matters because the capture path is the one thing that must never fail. A parse that goes wrong is recoverable — the trip lands in offered_parse_failed with its raw OCR text intact, and the driver can correct the fields by hand. A crashed ingest endpoint is not recoverable, because the offer is gone in ten seconds and will never be shown again.

Parsing hostile input PARSER

The offer screen is not a document. It is a live UI with promotional overlays painted across it — “Exclusive”, “Towards your destination”, “Fast charger” — any of which OCR will happily interleave into the text stream between the fields that matter.

The parser therefore works positionally rather than by naive pattern search: it locates the nth duration/distance pairing to distinguish trip from pickup, filters currency lines out of star-rating detection (a fare and a rating are both decimals), and truncates addresses at a UK postcode or country token so trailing overlay text is discarded rather than absorbed into the address.

An event log, not a status column

Trips move through offeredacceptedcompleted, with rejected and cancelled as terminal alternatives. Transitions are validated explicitly: completing a trip that was never accepted returns 409 invalid_transition_to_completed rather than silently succeeding.

Every transition appends to trip_events, and critically each event carries a snapshot of the prior state in its payload — status, timestamps, runtime delta, traffic level.

That one design choice makes undo nearly free. The undo endpoint reads the last status event, restores the prev object wholesale, and appends a status_undo event of its own. There is no reverse state machine to write and keep correct, and no history is destroyed — undo is itself an event.

A driver tapping Accept by mistake on a moving vehicle is not an edge case. It is Tuesday. Undo had to be one tap and impossible to get wrong.

Corrections route through the engine CORRECTNESS

When OCR misreads a fare, the driver edits the field in the dashboard. The corrected values are rescored through the same scoring function as the original parse — not patched into the stored verdict.

This sounds obvious and is easy to get wrong under deadline: writing the corrected number straight to the row is quicker and leaves the verdict stale, so a trip can end up displaying a fare and a pay status that contradict each other. Forcing every write through one scoring path means the invariant holds by construction.

Closing the loop on the estimate

The platform's quoted trip time is an estimate. What actually happened is knowable only after the fact — and the system already holds both numbers.

On completion, the elapsed time between acceptance and completion is compared with the original estimate, and the difference is graded into a traffic level from 1 to 10. This converts every finished trip into a data point about the route and the hour.

runtime_delta = actual_minutes − estimated_minutes → traffic_level

The grading is not a plain threshold ladder. Long trips absorb small absolute overruns — three minutes late on a forty-minute journey is noise, not congestion — so any trip of twenty minutes or more that lands within ±10% of estimate is classified as normal flow regardless of the absolute delta. Judging a delay in minutes alone would systematically over-report congestion on exactly the long trips the engine most wants to recommend.

Keeping report generation off the request path

Daily exports are rebuilt whenever a trip changes. Doing that synchronously would make every status tap wait on file generation; doing it naively in the background would let concurrent rebuilds of the same date race each other.

The export queue is a coalescing single-flight worker. Pending dates live in a Set, so ten changes to today collapse into one rebuild. A running flag guarantees only one drain is ever in flight, and re-entrant calls return immediately rather than queueing behind each other. Work is scheduled with setImmediate, so the HTTP handler returns first and the rebuild happens after the response is sent.

Failures are captured into lastError and surfaced through a status endpoint instead of being thrown into a background context where nothing can catch them — an unhandled rejection in a detached worker is invisible until it takes the process down.

From single driver to product

The system began as a single-tenant tool backed by SQLite. Turning it into something other people could pay for meant reworking the data layer rather than bolting authentication onto the front.

Storage moved to PostgreSQL behind a factory-pattern wrapper that binds every query to a tenant. The point of the wrapper is that tenant scoping is not something a developer has to remember on each call site — a query that forgets its tenant is not a data leak waiting to happen, it is a query that cannot be constructed.

On top of that sit cookie/session authentication with tenant and admin roles, Stripe checkout with subscription-gated access, Web Push over VAPID for offer alerts, an admin console, and a live operations view at /ops/live. Deployment is PM2 behind Nginx.

Outcomes MEASURED

ResultHow
~98% OCR accuracymulti-stage validation with fallback logic
~90% less manual processingend-to-end capture → structured JSON
9 metrics per offerpure scoring function, no I/O

The OCR figure is the one that mattered commercially. A parser that is right most of the time is worse than useless on a decision that costs money each time it is wrong, which is why the fallback path and the manual correction loop exist at all.

Verification PRACTICE

The scoring engine is the part of the system where a silent error is most expensive: a wrong verdict does not throw, it just quietly costs the driver money on every offer.

The browser port powering this site's demo was validated against the production module by differential testing — hand-picked threshold boundaries plus randomised fuzz across 20,009 offers and 180,081 field comparisons, with every assignTrafficLevel input pair in range checked as well. Zero mismatches. That is the standard the demo on this site is held to, and it is why the numbers it renders can be trusted to be the real ones.

See it run

The scoring engine described here runs live on this site against synthetic offers, including the ones built to fool a naive filter.

Open the demo →