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.
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.
Five stages, each independently testable, with the scoring engine deliberately isolated as a pure function of parsed fields.
| Stage | Module | Responsibility |
|---|---|---|
| Ingest | server.js | Multipart upload from an iOS Shortcut, one endpoint, no session needed on the capture path |
| OCR | ocr.js | Provider abstraction over Tesseract and Google Cloud Vision, selected by env var |
| Parse | parser.js | Regex-driven field extraction from noisy OCR text, with overlay rejection |
| Score | scoring.js | Pure function: parsed fields in, nine metrics and two verdicts out |
| Lifecycle | db.js | State 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.
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.
| Band | Trigger | CLOSE | SLIGHTLY 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.
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.
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.
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.
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.
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.
Trips move through offered →
accepted → completed, 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.
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.
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.
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.
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.
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.
| Result | How |
|---|---|
| ~98% OCR accuracy | multi-stage validation with fallback logic |
| ~90% less manual processing | end-to-end capture → structured JSON |
| 9 metrics per offer | pure 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.
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.
The scoring engine described here runs live on this site against synthetic offers, including the ones built to fool a naive filter.