Scan to Item — Backend
The backend accepts a product identifier and returns a draft, offers, images,
confidence, and the evidence used to choose each field. It is written in
TypeScript and runs as in-process modules inside the arda-frontend-app BFF,
behind POST /api/enrichment/import, mirroring /api/amazon/import.
Responsibilities
Section titled “Responsibilities”The engine is responsible for:
- normalizing the scanned or typed source;
- selecting and running the providers that support that source;
- separating exact identity lookup from later product and supplier discovery;
- reconciling provider candidates into one reviewable result;
- preserving competing evidence and provider traces; and
- isolating provider timeouts and failures.
It does not create Items or ItemSupplies, own tenant-authored catalogs, or silently convert package quantities. The accepted draft continues through the existing Operations write path.
Current component design
Section titled “Current component design”The component view below separates identity providers, discovery, packaging suggestions, and deterministic reconciliation inside the engine.
The route classifies adapters as identity or discovery sources and starts identity lookups together. As each one resolves, its candidates and trace are added to the pending result. The first authoritative identity candidate lets discovery start immediately while slower identity providers keep running in parallel — this shortens total latency without changing the single-response contract.
The call resolves once every configured adapter has matched, returned no match, failed, or timed out, and returns one reconciled response. Provider completion order does not affect field selection.
API contract
Section titled “API contract”POST /api/enrichment/import in the arda-frontend-app BFF is a thin route
handler → route module → the in-process engine under
src/server/lib/enrichment, mirroring /api/amazon/import. The request is
{ input: "<barcode | product URL | free text>" }; classification is
server-side, not chosen by the caller. The call is synchronous: the route
waits for identity and discovery adapters to finish, reconciles their output,
and returns one response. There is no run resource, no idempotency key, and no
polling.
| Outcome | Status |
|---|---|
| success | 200 |
INVALID_REQUEST, UNRECOGNIZED_INPUT | 400 |
AUTHENTICATION_REQUIRED | 401 |
NO_MATCH | 404 |
ENRICHMENT_UNAVAILABLE | 502 |
ENRICHMENT_TIMEOUT | 504 — reserved; no current path produces it (an all-provider timeout surfaces as ENRICHMENT_UNAVAILABLE) |
The response is { ok: true, data: EnrichmentImportDto } or { ok: false, code: EnrichmentErrorCode, message }, the same discriminated envelope as
/api/amazon/import. Auth is getBffAuthHeaders (a Bearer access token),
identical to the Amazon route.
type EnrichmentImportDto = { name: string | null; brand: string | null; description: string | null; image: string | null; // primary; also images[0] images: string[]; // distinct product images, best first price: { amount: number; currency: string } | null; unit: string | null; // free-text package label, e.g. "pack of 12" unitCount: number | null; packaging: { // reconciled purchasing suggestions; null when none resolved unitOfMeasure: string | null; unitsPerPackage: number | null; minimumOrderQuantity: number | null; orderIncrement: number | null; priceBasisQuantity: number | null; } | null; barcode: string | null; // GTIN / UPC mpn: string | null; productUrl: string | null; // canonical source page confidence: 'matched' | 'needs_review'; offers: EnrichmentOffer[]; // ranked, canonical source first};Nullable fields follow the Amazon convention: null means no source provided
this, not an error.
Each EnrichmentOffer carries supplier, supplierSku, title, url,
image, price, and verdict. Offers are ranked best verdict first; the
top-level price and productUrl are recomputed from the final merged offer
list, so an exact Amazon offer is not buried behind a related engine offer.
The frontend can show its own loading state while the request is in flight — a scan can take a few seconds while identity and discovery adapters run — but that is presentation only. One POST returns the full, reconciled result; the frontend never issues a second request to check on it.
Inputs and future OCR support
Section titled “Inputs and future OCR support”The current source field accepts three forms:
| Input | Normalization | Current use |
|---|---|---|
| 8–14 digit barcode | Removes spaces and hyphens and stores a barcode key | Exact catalog and marketplace identity lookup |
| HTTP or HTTPS URL | Canonicalizes the URL | Fixture or supplier matching; available to extraction adapters |
| Any other non-blank string | Normalizes case and whitespace for the lookup key | Fixture and supplier matching; available to extraction adapters |
Plain text is a search input. The BFF fans out to Amazon’s /api/amazon/search
and to Exa, and the reconciler merges the two. These results remain
needs_review because keyword relevance is not exact product identity.
Amazon product URLs use the fast identity path. The Amazon import route
validates the host,
extracts the ASIN from supported /dp/, /gp/product/, or /gp/aw/d/ paths,
calls Amazon GetItems, and rejects a response with another ASIN. That exact
draft is published before optional discovery begins. Lookalike hosts and URLs
without a supported ASIN path do not reach Amazon at all.
OCR does not need a separate reconciliation system. The clean extension is an image input that references an uploaded asset, followed by an OCR adapter that emits text, barcode, and product candidates with evidence. Exact identifiers can enter the existing identity stage; descriptive text can enter discovery or model extraction and remain non-authoritative until corroborated.
The engine’s internal input currently accepts only string source plus
optional hints and provider selection, so image support needs an input-shape
addition. Two reasonable shapes are:
- upload the image through an existing file service, then call
/api/enrichment/importwith a discriminated input such as{ "input": { "type": "image", "assetId": "..." } }; or - expose a dedicated image-enrichment endpoint that accepts multipart upload
and returns the same
EnrichmentImportDtoshape.
An asset reference is the better default because it keeps large payloads out of the import request and makes authorization and retention explicit. The adapter, provider choice, file limits, retention rules, and consent requirements are future work; the reconciler does not need to change.
Exact web fallback for catalog misses
Section titled “Exact web fallback for catalog misses”Structured barcode catalogs do not cover every product. During the prototype,
0810086790630 returned no match from Barcode Lookup, UPCitemdb, and Amazon.
An ordinary exact web search found a supplier page that printed the UPC, while
the normal five-result Exa search did not. The local demo now has a fixture for
that evaluated item, and Exa also has a miss-only fallback for uncatalogued
barcodes.
When the identity stage produces no candidate, Exa switches from the normal
five-result fast query to a quoted, 25-result auto query and requests page
text. The adapter accepts a result only when the title, URL, highlights, or
returned page text contains the same normalized barcode. The candidate keeps
the page URL as evidence and remains non-authoritative and non-cacheable, so
the run is needs_review.
Exa does not expose a strict lexical-search mode. It finds a broader candidate set here; the adapter’s exact page-text check is what makes acceptance deterministic.
This does not loosen the normal path. Catalog matches still use their existing exact checks, and resolved products still use the smaller Exa discovery query. The wider request is only paid for after a complete identity miss.
Provider pipeline
Section titled “Provider pipeline”| Source | Stage | Acceptance and use |
|---|---|---|
| Barcode Lookup | Identity | Uses the exact barcode response for product identity, images, and merchant offers |
| UPCitemdb | Identity | Uses the exact barcode response for identity, images, and merchant offers |
| Open Food Facts | Identity | Uses an exact barcode response for food identity and package data |
| Fixture catalog | Identity | Supplies deterministic products in local development |
| Supplier sample catalog | Identity | Supplies bundled Grainger, Uline, and McMaster examples in local development |
| Exa | Discovery | Uses a five-result fast search for resolved identity and requires exact barcode or manufacturer-part evidence; after a total barcode miss, uses a quoted 25-result auto search and still requires the barcode in returned page content; plain text returns non-authoritative suggestions |
| OpenAI packaging | Discovery | Uses the resolved product fields to suggest unit type, units per package, minimum order, order increment, price basis, and a package label; every value is non-authoritative and reviewable |
The engine has no Amazon adapter of its own: Amazon
URL/ASIN lookups delegate to /api/amazon/import, and freeform text fans out
to /api/amazon/search alongside Exa, merged by the same reconciler. See
Front door: wrapping Amazon.
Exa runs after identity resolution and remains discovery evidence. A plausible web page cannot make an otherwise unverified product authoritative.
The packaging adapter calls the OpenAI Responses API with a strict JSON schema.
It defaults to gpt-5.4-nano and returns null for fields it cannot support.
It is enabled automatically whenever OPENAI_API_KEY is set, so once that key
exists in an environment it runs on every scan within a three-second budget. Set
ENRICHMENT_PACKAGING_MODEL_ENABLED=false to force it off. The adapter cannot
change the product name, brand, identifiers, supplier, or price. It only ever
receives resolved product fields, never the user’s raw input. The reconciler records each proposed
packaging field as openai-packaging evidence, and the frontend asks the user
to confirm non-default quantities and units before saving.
The adapter interface is the main place this grows. New sources plug in behind the same identity and discovery stages and the same reconciler, so later product features are additive rather than special cases — a tenant catalog adapter that matches a scan against a tenant’s own approved items and SKUs before any external call, or a customer-supplied match set such as an uploaded price list or parts catalog. Each new adapter keeps the same evidence, authority, and cache rules as the built-in ones.
Source alternatives evaluated
Section titled “Source alternatives evaluated”UPCitemdb is useful as a second general barcode catalog, although product and merchant quality vary by category. Open Food Facts has detailed open package data for food but does not cover Arda’s broader inventory. Amazon adds exact marketplace identity and current commerce data, with credential and freshness constraints.
Imported tenant, supplier, and manufacturer catalogs could provide higher-value aliases, package quantities, supplier SKUs, and specifications. They need ingestion, ownership, and refresh workflows that do not exist in the prototype. Generic web search and model extraction have broad reach but require more extraction and corroboration; Exa was chosen because it supports resolved product context, supplier profiles, domain constraints, and exact-identifier evidence.
Front door: wrapping Amazon
Section titled “Front door: wrapping Amazon”/api/enrichment/import is the only route the frontend calls. Classification
in normalizeSource decides how each input reaches Amazon; three of the four
paths route themselves unambiguously, and one is a genuine merge.
| Input | Routed to | Amazon involvement |
|---|---|---|
| Barcode | Engine catalogs (Barcode Lookup, UPCitemdb, Open Food Facts) | None |
| Amazon URL / ASIN | Delegates to /api/amazon/import | Exclusive — Creators API GetItems, mapped 1:1 onto EnrichmentImportDto |
| Non-Amazon URL | Engine scrape (Exa contents) | None |
| Freeform text | Both /api/amazon/search and the engine’s Exa-search, merged by the reconciler | The one genuine overlap |
The Amazon route modules are imported lazily, on the Amazon paths only, so a barcode scan never loads the Amazon client or requires Amazon credentials to be configured.
The freeform-text merge is the crux under review: whether wrapping Amazon
behind one front door is worth the coupling it creates — for freeform text,
enrichment depends on the Amazon route being up — against keeping
/api/amazon/import and /api/enrichment/import as composable peer routes and
pushing that choice to the client. See Open decisions.
Reconciliation
Section titled “Reconciliation”The reconciler sorts candidates by authority first, then by source type:
- tenant;
- manufacturer;
- fixture;
- catalog;
- marketplace;
- supplier; and
- web.
The fixture position keeps local demos deterministic and is not a production
trust decision. When Barcode Lookup and Amazon both return authoritative but
conflicting field values, Barcode Lookup currently wins because catalog ranks
above marketplace.
The highest-ranked value becomes the draft field, while every competing value
stays in evidence. Confidence becomes matched when one authoritative source
has an exact identifier or when two independent sources have sufficiently
similar names. Other results remain needs_review.
Packaging is reconciled field by field rather than selecting one provider’s whole package object. A catalog can supply units per package while the model suggests a minimum order or unit label; each selected and competing value keeps its own evidence record.
Offers are evaluated separately:
exactmeans identifier, title, and available package evidence agree;equivalentmeans the product matches but package or presentation differs; andrelatedis a useful lead that should not be treated as the scanned item.
Images are deduplicated by URL. Placeholder and logo URLs are demoted, authoritative candidates come next, and known or inferred resolution breaks remaining ties.
Magic numbers
Section titled “Magic numbers”The current values were chosen while testing known barcodes and specific failure cases. The live suite has nine cases: two barcode identity matches, a negative short-code case, and six supplier-URL cases; unit cases cover unrelated offers, package mismatches, corroboration, source precedence, and image choice.
| Value | Used for | Basis |
|---|---|---|
tenant 7 → web 1 | Source ordering | Represents the intended order. Only relative order matters; authority is checked first. |
0.2 name overlap | Offer title guard | Allows minor title differences while rejecting titles with almost no shared words. |
0.5 name overlap | Offer equivalence and source agreement | Requires at least half of the combined distinct words to overlap. |
2 character token minimum | Name comparison | Removes one-character words that add noise. |
2 agreeing sources | Confidence | Allows independent corroboration to produce matched without an authoritative source. |
exact 0, equivalent 1, related 2 | Offer sorting | Preserves the order shown to the user. |
640,000 inferred pixels | Image ranking | Treats a zoom URL as roughly 800×800 when dimensions are missing. |
4 images and 5 offers | Candidate limits | Keeps one catalog from flooding the response. |
5s / 10s | Total wall-clock budget: barcode / URL-and-text | Enforced across both phases; an adapter’s own hint can shrink but never exceed the remaining allowance. Set from live measurements: barcode identity resolves in ~2–3.5s, unbounded supplier-URL crawls measured 6.5–13.7s, so the slowest pages are traded for a bounded wait. |
4s / 3s | Exa / packaging-model budget hints | Reflects the expected relative latency of web-search and small-model calls, within the total budget above. The Exa budget applies to either its normal or miss-only request, not both. |
As the evaluation set grows, each failure should become a case with the expected identity, offer, package, and image choice. Change a threshold only when it fixes that case without breaking the existing set. When several values work, prefer the value that avoids false matches.
These prices were checked on 13 July 2026. Provider plans change, so the engine should record the provider-reported request cost when available and the team should recheck this table before committing to a production volume.
| Provider | Published price used here | Cost in the current flow |
|---|---|---|
| Barcode Lookup | Starter: $99/month for 5,000 calls; Advanced: $249 for 25,000; Professional: $499 for 100,000; Enterprise: $949 for 500,000 | At full use, about $0.0198, $0.00996, $0.00499, or $0.001898 per call. The monthly fee is paid even when volume is lower. |
| Exa | Search starts at $7 per 1,000 requests; returned content and larger result sets can add cost | The normal five-result fast search was $0.007 in the live check. The 25-result catalog-miss query with page text was $0.022. Its costDollars response should be the accounting source of truth. |
| OpenAI GPT-5.4 nano | $0.20 per million input tokens and $1.25 per million output tokens | An estimated 800 input and 120 output tokens costs about $0.00031. Actual token use should be logged by model and adapter version. |
| UPCitemdb | Explorer: 100 requests/day; DEV: $99/month for 20,000 lookups/day; PRO: $699/month for 150,000/day; documented overage is $0.04 per 100 | Marginal cost is zero inside a purchased quota, but the subscription still belongs in the monthly allocation. Overage is $0.0004 per call. |
| Open Food Facts | No metered fee is documented for the public API; product reads are rate-limited | Treat it as rate-limited shared infrastructure, not unlimited free capacity. |
| Amazon Creators API | No per-call API fee is documented | Access, attribution, usage policy, and sales-linked rate limits are the constraints. Amazon commerce data is not a general-purpose cache. |
For a default barcode scan on the Barcode Lookup Starter plan, assuming every scan also runs Exa and the packaging model, the fully utilized marginal model is approximately:
$0.0198 + $0.007 + $0.00031 = $0.02711 per scan.
That 2.71-cent figure is only reached when the 5,000-call Barcode Lookup quota is used. Lower volume has a higher effective cost because of the fixed monthly fee:
| Monthly scans | Barcode Lookup tier | Barcode + Exa + model | Effective cost per scan |
|---|---|---|---|
| 1,000 | $99 Starter | $106.31 | $0.10631 |
| 5,000 | $99 Starter | $135.55 | $0.02711 |
| 25,000 | $249 Advanced | $431.75 | $0.01727 |
| 100,000 | $499 Professional | $1,230.00 | $0.01230 |
The table excludes taxes, failed-call credits, UPCitemdb subscriptions, and provider plan changes. It assumes one Exa search and an 800-input/120-output model call for every scan. Skipping discovery or the model reduces cost. Catalog misses that use the wider Exa request add about $0.015 over the normal Exa assumption in this table.
An Amazon URL or ASIN request delegates to /api/amazon/import and does not
call Exa or the model; it has no metered provider charge identified in the
published Amazon documentation. A default Amazon URL request returns the exact
Amazon draft first, then may spend about $0.00731 on Exa and
packaging suggestions while the user is already reviewing the form.
Cost reporting should separate fixed subscription spend, provider-reported variable cost, and effective cost per successful draft. The useful operational metrics are cost per submitted scan, cost per completed enrichment call, and cost per accepted item; cost per API call alone hides no-match and retry waste.
Failure handling
Section titled “Failure handling”Each provider runs with its own timeout and returns a trace with status,
duration, and candidate count. One failed or timed-out provider does not stop
the others. A run fails only when every applicable provider fails; successful
lookups with no candidates finish as needs_review.
The route wraps the whole engine call: an unexpected failure — a provider
throwing, or adapter setup failing — is reported to Sentry (tagged
route: enrichment.import) and degrades to ENRICHMENT_UNAVAILABLE rather than
an unhandled 500. Provider errors are never returned as raw bodies to the
client. Built adapters are cached across requests, so a warm instance does not
rebuild them or re-read the environment per scan. Fuller structured logging
(request ID, per-provider metrics) is still future work.
Retries need provider-specific treatment. Safe GET-like catalog lookups can be retried within the request’s provider budgets; rate limits and non-idempotent provider behavior must be handled explicitly rather than through a blanket retry policy.
Tenancy and security
Section titled “Tenancy and security”The engine can scope by tenant — the reconciler ranks tenant sources highest and the supplier catalog filters by tenant — but the BFF port does not thread a real tenant yet: every run uses an empty tenant context. Wiring it is future work and needs the BFF to authenticate the Arda session and derive tenant and author from trusted claims before enrichment reads anything tenant-owned.
Provider credentials stay in the BFF’s server-side environment. They do not appear in the browser bundle, response, source evidence, or source traces.
There is no tenant projection in the BFF port today. When one is added and wired to Operations, that read must use the authenticated tenant context and must not let aliases, catalog records, or supplier policy cross tenant boundaries.
State and persistence
Section titled “State and persistence”The BFF port holds no durable state: each scan runs the adapters and returns a result, with nothing written between calls. Result memory and tenant catalogs are design targets, not current behavior.
| State | Current implementation | Future work |
|---|---|---|
| Result memory | None — every scan re-runs the providers | Decide whether reusable data is provider output, user-approved output, or both, then add a store |
| Tenant catalogs and policy | None wired | Operations-owned projection or module integration |
The intended design when this is built:
- Result memory: a two-method store (
find/put) keyed by tenant plus the normalized source key. Write a result only whenmatchedand every contributing candidate is cacheable, so live commerce data never enters memory. In the synchronous route this means: check memory before the provider fan-out, return a hit immediately. - Tenant policy (
preferredSuppliers,allowedSupplierDomains,categoryHints) already has a consumer: the Exa search adapter in the BFF reads it to target supplier domains. Only the projection reader that feeds it from Operations is missing — thread the authenticated tenant and implement the reader, and the engine side works as-is.
Observability and evaluation
Section titled “Observability and evaluation”The enrichment response already records provider status, duration, candidate
count, field evidence, and selected values. Unexpected failures report to Sentry
tagged by route. Because Barcode Lookup requires its key as a URL query
parameter, the Sentry server config redacts secret-style query parameters
(key, token, and similar) from breadcrumbs, request URLs, and trace spans
before an event is sent. Future telemetry should aggregate:
- provider latency, timeouts, failures, and rate-limit responses;
- matched, needs-review, and no-match rates;
- first-adapter and total request latency;
- coverage and false-match rate from reviewed results;
- provider request cost, token use, and fixed-plan allocation; and
- fields changed by the user before save.
The current live corpus has nine cases: two barcode identity matches, a negative short-code case, and six supplier-URL cases. The next evaluation should use 30–50 physical items from real Arda environments, with ground truth recorded before the lookup. Production promotion should require the precision, coverage, latency, resilience, and credential-safety gates in the overview.
Future work
Section titled “Future work”- Wire
TenantProjectionReaderto approved aliases, existing items, and supplier preferences from Operations. - Decide what result memory means, then define provider-specific freshness and invalidation rules.
- Replace bundled supplier samples with an owned ingestion and refresh path.
- Evaluate packaging suggestions against the physical-item corpus and tune the prompt, allowed unit vocabulary, and UI review language from those results.
- Decide whether URL and free-text identity extraction needs another model adapter beyond the implemented Amazon and Exa search paths; keep any such identity non-authoritative until corroborated.
- Define the image asset contract and add an OCR adapter that produces normal identifier and text evidence.
- Evaluate the miss-only Exa path across more uncatalogued barcodes and monitor its exact-match rate, latency, and reported cost.
- Add structured metrics and session-derived production auth.
Runtime and ownership alternatives
Section titled “Runtime and ownership alternatives”Enrichment runs in the arda-frontend-app BFF, at POST /api/enrichment/import
next to /api/amazon/import. That route already attaches auth and keeps
provider secrets on the server, so enrichment just reuses it — no new service
to deploy, no new secret shape, and no Operations change beyond the provider
keys the prototype already needs.
Alternatives considered: a standalone service (duplicates the auth and secret handling the BFF already has) and an Operations module (one fewer deployment, closer to the tenant catalog, but a larger change than reusing the Amazon route).
- Enrichment engine — in-process modules in the BFF, with the engine under
src/server/lib/enrichment/engine/ - Import route
Copyright: © Arda Systems 2025-2026, All rights reserved