Commit Graph

65 Commits

Author SHA1 Message Date
00317a00d8 secert updated 2026-09-24 17:20:04 +05:30
299871b820 shifts 2026-09-24 15:51:47 +05:30
bb14445e21 agent fix 2026-09-24 12:36:33 +05:30
9698de32d5 api key integration 2026-09-24 11:01:16 +05:30
697b77f8c1 agent 2026-09-23 17:26:13 +05:30
8e1549764b Nearle Buddy answers a typed question
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>
2026-09-23 13:13:20 +05:30
bb5f40926f Add the assistant tool registry and its first tool
Phase 1 of Nearle Buddy: an agent names a tool, and the registry decides
whether that is allowed, whether the arguments make sense, who is asking,
and what gets recorded — then runs a handler a person wrote and tested.

No agent gets raw table access. The usual argument for tools over
generated SQL is safety; here there is a harder one. The fields on this
backend do not mean what their names say, and it is measured:
orders.deliverystatus is an empty string on all 181 rows of tenant 1147,
orders.orderstatus never carries the six middle delivery stages,
deliveries.ridername holds statuses as often as names, deliverytype is
empty on every row in production. A model writing SQL gets each of those
wrong with no error — it reports a cancel rate from a column of empty
strings and nobody can tell. A model calling a tool cannot, because the
correction lives in the handler beside the measurement that justified it.

Call does five things in order: find the tool, check the agent's
allow-list, validate arguments, confirm the caller is scoped to
something, run the handler — writing exactly one audit row whatever
happens, refusals included. A trail of successes answers "did anything
try to read another tenant?" with silence, which reads the same as no.

The model has no say in whose data is read. stuck_orders has no tenantid
field on its schema — absent, not rejected — and the tenant comes from
the session claims added in the previous commit. Arguments the tool did
not declare are dropped rather than passed on, so a model sending a
`where` clause gets it discarded.

stuck_orders: deliveries a rider was given and has not accepted, ten
minutes for a look, twenty-five for somebody now. Derived from assigntime
and orderstatus, so it does not depend on anyone having been watching.
Carries the wait in minutes, what to do, where to check it, and what it
covered. A capped answer says so — an empty result and a truncated one
look identical to a model and it will call both "none".

The audit sink writes to the log for now; a database sink is phase 8.
Nothing calls the registry yet: the loop and the model gateway are phase 2.

37 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:22:57 +05:30
24339a8b51 Merge origin/main: keep the substring rule, read its tie
main had moved on with retrieval work validated against real queries —
minTokenHits (the word match needs two thirds of the label, not all of
it), separator folding so "Parle G"/"Parle-G"/"ParleG" all reach Parle-G,
the floor at 0.50 after "Paracetamol" came back as "Paneer Makhni 500ml"
at 0.304, and ties broken on cosine distance instead of name. All of that
is kept exactly as it was.

The conflict was in textScore: this branch replaced the substring rule
with a coverage formula to stop a bare brand name resolving to one
arbitrary product. That is the wrong half to change. The substring rule
scores every product of a brand 0.95 IDENTICALLY, and that tie is not the
bug — it is the signal. isAmbiguous reads it, so the branch's coverage
rewrite is dropped and the ambiguity layer alone does the work:

  "britannia" → all 258 rows tie at 0.95 → ambiguous: true + candidates
  "Parle G"   → folding and the single-character token still land it
  a real name → runner-up far behind → match, unchanged

Dropped with it: scanSpecificEnough, the per-hit text score, and the
proportional confirmation bonus — the flat +0.10 is back. Simpler, and it
leaves main's tuning untouched.

TestTextScoreRewardsSpecificityNotJustOverlap tested the removed formula
and is replaced by TestABrandNameScoresItsProductsIdentically, which
guards the tie itself: a formula that broke it on name length or word
count would bring the bug back.

Docs carry both rationales, and now say plainly that confidence stays
high on the ambiguous path — gate on `ambiguous`, never on `confidence`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:05:03 +05:30
01bc89ab77 Ask instead of guessing when a label fits several products
`"britannia"` is a substring of all 258 Britannia product names, and
textScore returned 0.95 for any product whose name contained the label.
So every one of them tied, the tie broke alphabetically, and the customer
was shown one arbitrary biscuit with "confidence": 0.95 and a price. Lens
hands back a bare wordmark often — it is usually the biggest thing printed
on a packet — so this was the common case, not an edge one. Found via the
example request in the mobile team's own proposal.

Scoring now asks both questions. A hit carries `score` (ranks) and `text`
(how specifically the label names THIS product: the harmonic mean of how
much of the label the product explains and how much of the product's name
the label explains, pack sizes dropped from both sides). A brand name
scores its products ~0.33 equally instead of 0.95 arbitrarily. The
"vector and text agree" bonus is now proportional to the text score, so a
weak match can no longer inflate a whole brand.

isAmbiguous reads that: the leader is a guess if anything is level with it
(margin) or if the label names no one product (specificity), and then the
response carries `ambiguous: true` with `candidates` — distinct products,
not pack sizes, at most ten, each marked with whether one of the
customer's stores has it in stock, available ones first. `match` is nil
and `stores` empty on that path: no price for a product nobody chose.
Erring towards asking is deliberate — a tap versus the wrong biscuit.

To act on a pick, /lookup now accepts `brand` + `catalogueid` instead of a
label and skips recognition entirely (also serves deep links and re-order).
New: ScanRepository.CatalogueRef, resolving via the brand tables discovered
from information_schema, never a name built from the request.

Also: scratch/cataloguedims now reports every vector column, not just
`embedding` — which is how we learned the catalogue also carries
img_vector(1024), filled on 1885 of 2124 rows. SCAN_TO_ORDER.md records
why that column stays unread for now and what would change it, alongside
why the app is not asked to compute vectors on the phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 10:56:02 +05:30
2f1501883b e2e changes 2026-09-16 17:09:04 +05:30
28af3e05f2 image search test 2026-09-16 11:52:35 +05:30
42ea007fe7 image search 2026-09-16 11:34:18 +05:30
72907dae74 Scan-to-order: label from the customer's camera to "buy it here"
POST /v1/mob/scan/lookup   label + customer → catalogue match, sizes, and
                           every registered store that sells it with live
                           stock, in-stock first / nearest first, one
                           recommended
POST /v1/mob/scan/confirm  chosen store + size + qty → re-read the ledger;
                           ok, or the next-nearest store with enough of the
                           same product
GET  /v1/mob/scan/stores   registered stores nearest first

Recognition is pgvector cosine search over every brand_* table (each
with its own index, merged) plus a word match that settles near-ties
and works alone when no model is configured. The embedder is chosen by
EMBEDDING_PROVIDER (OpenAI-compatible or Gemini) and must be the model
that indexed the catalogue: verified 2026-09-15 as all-MiniLM-L6-v2 over
search_query, served by the cluster's Ollama as `all-minilm`; the first
search refuses a width mismatch by name.

Customer, stores and catalogue are read concurrently under a 5 s cap; a
slow model degrades to a text answer. Vectors and ranked hits are cached
in Redis and in-process; live stock never is. Availability uses the same
rules as the customer catalogue (approve, publishedat, ledger balance,
outlet price else retail). No stock reservation: confirm re-reads.

scratch/cataloguedims reports the catalogue's embedding width and fill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:04:34 +05:30
1633617dc4 Stop reporting a failed login lookup as "Invalid Email"
GetUserByAuthname / GetUserByContactNo / GetUserLogin discarded the
Scan error, so a database that could not answer — down, pool exhausted,
or booted without its config (2026-07-20) — came back as uid 0 and every
user was told their email was wrong.

One lookup, GetUserLogin, now returns an error; sql.ErrNoRows is "not
found" and anything else reaches the service, which answers 500 "Login
is temporarily unavailable" and logs the cause. 409 "Invalid Email" is
unchanged for a genuine no-match: the console reads that exact shape as
"not registered". NULL password/role columns scan through sql.Null* so
they do not become 500s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:04:33 +05:30
8cc567c89b daily merchant app login fix 2026-09-11 11:10:25 +05:30
e7577fe0cf rider partner 2026-09-09 15:44:27 +05:30
62a441d798 profile name change 2026-09-08 19:21:23 +05:30
e87f09c4f2 console categories 2026-09-07 17:53:35 +05:30
c699a400c3 product categories 2026-09-07 17:28:38 +05:30
593b11f1b8 rider creation 2026-09-04 16:02:47 +05:30
18a0f9b34d pricing 2026-09-03 16:23:29 +05:30
7ed821e8b0 pricing 2026-09-03 11:21:12 +05:30
da0e9d987e variants as a single product 2026-09-03 10:43:05 +05:30
09efc5403f orders 2026-09-02 15:37:59 +05:30
68871cb043 product variant id 2026-09-02 13:11:01 +05:30
9f9b05ed1c tenant profile 2026-09-02 12:30:05 +05:30
1e3fe88e87 user accounts 2026-09-01 13:53:22 +05:30
ab74e70ba5 login bug 2026-09-01 13:17:52 +05:30
485f31239d guide changes 2026-09-01 12:01:43 +05:30
bc10b589cd fix on stocks on store catalogue 2026-08-31 16:26:06 +05:30
d55f101834 fix on shelf 2026-08-31 15:51:36 +05:30
4bce5ac854 product on shelf 2026-08-31 14:59:48 +05:30
ffc66462fc changes on agent 2026-08-31 12:33:10 +05:30
76554d26e5 bugs on variant id 2026-08-29 16:45:47 +05:30
40500f936a variant and price 2026-08-28 16:54:55 +05:30
c31f3aca27 changes with app variant 2026-08-28 13:02:03 +05:30
aef105979a changes with app product stock 2026-08-28 12:43:07 +05:30
46da26c452 changes 2026-08-28 11:56:57 +05:30
edb9c2803f lat and long 2026-08-27 13:59:16 +05:30
ff72af9a8a changes fixed in the catalogue 2026-08-11 17:10:21 +05:30
fba42259ea fix in the product import 2026-08-11 15:49:04 +05:30
c179e4d642 product images added to the catalogue 2026-08-11 15:05:27 +05:30
d35caf34d3 pos login edited with phone number 2026-08-11 11:08:43 +05:30
Suriya
cd2459dbb6 Let an admin create till staff from the console, through the same code
An admin sets a shop up from a browser; a supervisor adds a cashier at the
counter. Both had to be possible, and only the second one was.

So the console gets createposuser / updateposuser / getposusers /
deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the
same service methods `/pos/users` calls. Not a parallel implementation: a
supervisor created from a browser is the same row, with the same PIN rules, the
same duplicate check and the same identity-column allocation, as one created at
a till. Two paths writing one table is precisely how the two stop matching, and
this codebase already had that happen once.

configid is inferred rather than asked for. It is a number nobody looks up, it
varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting
it wrong creates somebody who cannot sign into the portal their colleagues use
and is invisible to half the platform's queries.

/posroles is served rather than left to the console to hardcode. A console that
knew supervisor was 7 would be wrong the day that changed and would have no way
to find out.

The outlet is the real difference between the two doors. A terminal proves it
with a signed token; the console asserts it, and is checked against the tenant
before anything is written. That is weaker, and it is worth being plain about:
these mint till credentials on an unauthenticated request, exactly like every
other route in the /v1/web and /v1/mob groups, because there is no auth
middleware on the web API at all. Documented as the weakest point in the design
and flagged to move behind a session guard once the console can hold one. The
terminal routes are untouched by it.

Proven in a rolled-back transaction against live data: the console creates a
supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true,
the till's /pos/staff sees them alongside the two created at the counter, and
0451, 1234 and a duplicate PIN are each refused with the same message the
terminal gives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:42:27 +05:30
Suriya
4b27b84b1f Let a shop run its own counter: supervisor and cashier, created from the till
A shop had no way to add the people who work in it. The terminal fell back to
three names and three PINs compiled into the app — the same three on every
install — because there was nothing for it to fall back *from*.

Two roles now exist in `app_roles`: Supervisor (7) runs the terminal and creates
staff, Cashier (8) bills. Fixed ids, written by hand, because that table has no
sequence and every id in it was assigned the same way. configid is left NULL
rather than duplicated per portal: a till is a till whichever portal a tenant
uses, and Admin already appears twice in that table for exactly that reason.

`/pos/users` is CRUD over them, and `/pos/login/pin` signs a cashier on at a
terminal a supervisor has already opened.

The rule every one of these follows: **tenant and outlet come from the caller's
token, never from the request.** There is no location field on the create body
to get wrong. A supervisor at Selvapuram cannot create staff at R mart, for the
same reason a till cannot bill into another shop's books — it is the same
inversion applied to people instead of sales.

PIN sign-in is deliberately behind the guard. Four digits is ten thousand
guesses, which is no barrier to an anonymous caller; requiring a session means a
real password opened the terminal first and the guesses are confined to one
outlet's own staff. The session it mints is fresh rather than derived, so a
cashier taking over from a supervisor drops their permissions instead of
inheriting them.

Three things the schema forced:

- A PIN cannot start with zero. `app_users.pin` is a bigint, so "0451" stores as
  451 and reads back as three digits — a cashier would type four and be refused
  for ever. Live data already holds one such account. Rendering refuses to show
  a PIN it cannot represent, rather than showing a short one nobody can type.
- `app_users` has no sequence either, so the next id is read and written inside
  one transaction behind an advisory lock. Two supervisors creating staff at the
  same moment would otherwise compute the same id and one insert would lose.
- 1234, 1111 and friends are refused outright. Live data has 1234 on eleven
  accounts and 1111 on nine.

Proven against outlet 1135, which had zero staff and was the reason the built-in
PINs were still load-bearing:

    created 9188  Store Supervisor  Supervisor  can_manage_staff=true
    created 9189  Counter Cashier   Cashier     can_manage_staff=false
    /pos/staff now returns 2        an unknown PIN is refused

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:22:16 +05:30
Suriya
c4dfcd5387 Serve a shop's own staff to the till, so the built-in PINs can retire
The terminal shipped with three names and three PINs compiled into it. Same
three on every install, readable by anyone with the APK, and permanent —
nothing anywhere could replace them.

`/pos/staff` answers with the people the back office says may ring a bill at an
outlet, and the same list rides down with the session so a till is ready to
trade the moment it signs in. The terminal writes them over its own and
deactivates whatever it had, which is what actually kills the seeded logins.

Two sources are unioned because the schema has two and neither is complete.
`tenantstaffs` is the table built for this and holds 12 rows on the entire
platform; `app_users.locationid` is where staff actually ended up. Either alone
returns nothing for almost every shop.

The endpoint takes no location parameter. The answer carries PINs, so the
outlet comes from the caller's token and a request without one is refused
whatever POS_AUTH_REQUIRED says — a till must not be able to ask who works at
the shop next door.

Rows with no PIN are dropped rather than sent: a name on screen nobody can sign
in as reads as a broken terminal rather than as an unfinished setup. Duplicate
PINs are dropped too, keeping the first — live data has 1234 on eleven accounts
and 1111 on nine, and two people sharing one would make the till attribute a
bill to whichever row it checked first.

The PIN travels in the clear over TLS, deliberately. Four digits are
brute-forceable in microseconds however they are wrapped, so hashing here would
buy the appearance of strength and not the substance — while costing something
real, since the terminal salts every PIN with its own salt before storing it
and could never verify a hash computed here. A PIN is shift attribution, not a
security boundary; the boundary is the session token.

Verified against live data, and it says the fallback still matters: outlet 1135
— the one the POS actually uses — has zero staff, and the only staff row found
anywhere is a delivery rider on PIN 1111.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:01:49 +05:30
Suriya
12165d5e58 Give the POS a real sign-in, and stop believing the store id on the wire
The POS surface was open. A till named its own outlet — `store_id` in a query
or in an ingest batch — and was believed, so one number changed in Settings
read another tenant's catalogue or posted bills into their books. There was no
middleware in the codebase at all, and the `JWT_SECRET_KEY` in the config was
read and never used.

Products were never mis-scoped: `resolvePosStore` already derived the tenant
from the location and the catalogue query already filtered on both. The tenant
was never taken from the wire. What was missing was any check that the caller
was entitled to the location they named.

So the outlet now comes *out* of a sign-in rather than going *in* from the
till. `POST /pos/login` authenticates against the same `app_users` rows the web
console uses — one account store, so deactivating a leaver closes both doors —
and answers with the outlets that account may reach, sealed in an HMAC-SHA256
token the terminal cannot edit.

Two checks then guard everything else, in order: the token verifies, and the
outlet named in the request belongs to the token's tenant. The second is the
one that matters — a valid token is a licence to name *your* outlets, not any.

Notes on the awkward parts:

- The guard reads the outlet from the body as well as the query. The two routes
  that write carry `store_id` in a JSON batch and never in the URL, so a
  query-only check would have left exactly the dangerous call unguarded.
- Three spellings of one thing survive — `store_id`, `locationid`,
  `location_id`. All three are read rather than normalised, because renaming
  them breaks terminals already in the field.
- `POS_AUTH_REQUIRED` defaults to false. Tills are billing real customers
  against the open endpoints right now and enforcing at deploy would stop every
  one mid-trade. A token is still verified when sent, and a wrong-tenant token
  still refused; the flag only governs requests carrying none.
- `POS_TOKEN_SECRET` has no baked-in fallback and fails loudly. A development
  secret in source is the same as no signature at all.
- `configid` is inferred when the till does not send it, because a person at a
  counter has no way to know theirs. `authname` is not unique in this schema —
  live data has one address twice under one configid — so an ambiguous match is
  refused rather than resolved by LIMIT 1, which could bill into the wrong
  tenant's books.

Verified against live data: 58 accounts across 34 tenants can open a till, an
account pinned to a location resolves to it alone, a tenant-level account gets
all six of its outlets, and a cross-tenant outlet request is refused.

Passwords are still plaintext platform-wide. Flagged at the comparison site;
fixing it is a migration touching every login path, not this endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:46:38 +05:30
bddd8fa265 product price 2026-08-04 10:58:26 +05:30
Suriya
8709556704 Add read endpoints for counter sales
The ingest only ever wrote. A bill that reached pos_orders was safe and
completely unreachable — no screen in the product could show it, and the
only way to see a day's counter takings was to query the database by
hand.

Three endpoints: a paged bill list, one bill with its lines, and a
summary split the three ways somebody actually asks for — by tender for
reconciling a drawer, by day for a chart, by till for an outlet running
several counters.

locationid is required on all of them and is the authorisation boundary,
so a caller cannot page through another shop's takings by omitting a
parameter. Fetching a bill under the wrong outlet returns 404 even when
the reference is a real one.

Dates match businessdate rather than arrival, because a till that was
offline overnight uploads yesterday's bills this morning and they belong
to yesterday. The list is ordered by billedat for the same reason —
sorting by arrival would interleave a recovered backlog through today.

Unlike the ingest handlers these answer in the usual envelope: they are
read by the web app, not by a terminal, and nothing about them is bound
to the till's contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:17:03 +05:30
Suriya
e3459a0f1c Ingest counter sales from the POS terminals, over MQTT and HTTP
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>
2026-08-03 17:48:00 +05:30