Brand case decides whether the catalogue-intelligence service answers at all.
Measured 30 Sep 2026:
/nutrition/Balaji/balaji_..._135g -> health_score 65.3, 545 kcal
/nutrition/balaji/... (our spelling) -> every field null
The brand list resolves ours to theirs, and /brands has slowed to 0.2-2.3s,
which exceeded the 3s client timeout on a cold start. The fallback then asked
under our own spelling, received a well-formed empty record, and cached it as
"no nutrition" for six hours -- so one slow moment silently removed nutrition
and health scores from every product of every brand, looking exactly like data
the agent team had not supplied.
Two changes:
- a result reached without a resolved brand is no longer cached, so the next
request retries rather than inheriting a wrong answer for six hours. A
genuine miss on a resolved brand is still cached, which is the case that
matters for traffic.
- the brand list is warmed in the background at startup, so no shopper is
ever in the path of that call.
Also logs which state the feature is in at boot, the way mail does. With
NUTRITION_BASE unset the endpoint simply omits `nutrition` and `healthscore`,
which is indistinguishable from an unscored product -- this deploy went out
without the variable set and had to be diagnosed by probing the API from
outside.
scratch/nutritionlive prints the exact response for any product by running this
code against the live product row and the live service.
NUTRITION_BASE=https://mcp.nearle.ai.in/api must be set in the deployment
environment. Unset, nothing changes and no product carries either key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2: the loop and the model gateway. The composer in the console has
said "Not connected yet" since it was built, because there was no
assistant endpoint anywhere. There is one now.
- utils/chat.go the gateway, a sibling of embedding.go: one small
interface, a provider switch, the shared postJSON, no
framework. Agents name a TIER (fast/balanced/deep) and
config maps tier to model, so changing provider does not
touch an agent.
- services/assistantService.go one loop for every agent. An agent is a
name, a tier, a prompt and an allow-list — data, not a
class — so a sixth is config rather than a subclass.
- the endpoint under /v1/web, inheriting middleware.WebAuth along with
every other console route. The assistant reads the same data the console
does and must read it as the same person.
What the model does not get to decide:
whose data the caller is built from the verified session in the
controller, never from the request body — there is no
tenant field to fill in. A test scripts the model calling
a tool with {"tenantid": 916} and asserts it ran for 1147.
which tools the registry enforces the agent's allow-list; a test
scripts a call to a tool the agent lacks and asserts the
handler never ran.
when to stop steps and tool calls are counted here. A model that keeps
calling tools is stopped by arithmetic, not by being
asked nicely.
Two quiet failures have tests of their own. A finish_reason of "length"
means the provider cut the reply off mid-sentence, which reads exactly
like a complete answer unless it is flagged. And a truncated tool result
reaches the model in words it will repeat — otherwise it describes a
capped list and an empty one identically.
A refused tool goes back as a message, not an error: a model told "that
tool needs a tenant" can explain it, where a model handed nothing says
"something went wrong".
Optional, like the embedder. Without ASSISTANT_PROVIDER the endpoint
answers "not switched on here", the composer stays disabled, and the tools
still work — they are ordinary Go functions, and only turning a sentence
into a tool call needs a model.
14 tests, against a scripted model rather than a live provider: these are
about what the loop refuses to let a model do, and that has to hold for
any model, including one behaving badly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`main.go` only ever loaded `.env`; the `APP_ENV` switch described in
`.env.local` / `.env.production` did not exist, and a missing variable
surfaced one restart at a time as a log.Fatalf inside db.Connect.
config.Load now picks `.env.<APP_ENV>` (default local) then `.env`, with
real environment winning, reads every setting into one typed Config and
reports everything missing in one message. Production insists on a POS
signing secret; local warns when DB_HOST is not a local address. db,
redis and the image store take the Config instead of reading env
themselves.
Also:
- livehub read MQTT_USERNAME while everything else uses MQTT_USER, so the
console stream connected to the broker unauthenticated. Both accepted.
- .dockerignore: `COPY . .` was baking .env.production into the image.
Dockerfile sets APP_ENV=production.
- Drop utils/config.go (dead viper loader) and create_table.go (unused,
hardcoded production DSN); go mod tidy removes viper.
- .env.example lists every variable the code reads; docs/ENVIRONMENT.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A till holds every bill in its own SQLite database and keeps it for
seven days after we acknowledge it, marking one synced only when its id
comes back in an ack. Everything here follows from that.
Silence is not acceptance, so a failing ingest publishes nothing at all
and the terminal simply sends again. A duplicate is a success, because
at-least-once delivery means a lost ack legitimately re-delivers bills
we already hold, and calling those failures would strand a day of
takings on the till. Deduplication is a unique index on the terminal's
UUID plus an advisory lock held for the transaction.
Bills land in pos_orders / pos_order_items rather than orders: a counter
bill carries a cashier, a terminal, a rounding adjustment, promos,
loyalty movement and a payment split that orders has nowhere to put, and
forcing one into the other loses whatever does not fit. Stock is *not*
split — a counter sale writes the same productstocks rows an app order
does, through helpers extracted from createOrderTx so the rule that
prevents overselling has one implementation rather than two.
GetRevenueSummary and GetSalesSummary were extended to union the new
table in; any new report has to remember the same.
Terminal health goes to Redis under a 90-second TTL, sharing the
instance the express backend uses. A heartbeat is a fact with an expiry
date: a till that loses power stops refreshing and ages off the board by
itself, where a Postgres row would need ~288k writes a day and a reaper.
Proven end to end against the live estate before commit: a bill over
HTTP and one over the real Mosquitto broker, the same bill three times
producing one row and one stock movement, and a heartbeat arriving on
the health endpoint. All probe data was removed afterwards.
Four things that only surfaced against real data. An unset jsonb column
failed the very first bill. Product SKUs are unusable as barcodes — 6,245
products share 93 SKUs and "1" covers 5,794 of them — against the till's
unique index, so barcodes fall back to the product id. A taxpercent of
-1 exists and would have put negative GST in a filed slab. And a product
with id 0 exists, which can never be billed and is now skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a separate CatalogueDB (pgvector) connection alongside the main
nearledb, plus a new catalogue module (repository/service/controller/
routes) to browse it by brand, category, and keyword, with brand
optional so the whole ~237-product catalogue can be browsed unfiltered.
Adds the actual bridge: importing a catalogue product snapshots it into
the tenant's own products table (keyed on brand+catalogueid, since a
catalogue row's bare id is only unique within its own brand table),
then links it via the existing productlocations upsert. Re-importing
tops up stock and refreshes price instead of duplicating. Also adds an
imported-refs endpoint so the frontend can badge already-imported items
without diffing full product lists, and wires the new AWS S3 image
store used to resolve catalogue product photos.
Bumps Go/Docker to 1.24 for the AWS SDK dependency this needs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>