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>
170 lines
7.4 KiB
Go
170 lines
7.4 KiB
Go
package facade
|
|
|
|
import (
|
|
"nearle/controllers"
|
|
"nearle/repositories"
|
|
"nearle/services"
|
|
"nearle/services/tools"
|
|
"nearle/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Facade struct {
|
|
UserController *controllers.UserController
|
|
ProductController *controllers.ProductController
|
|
OrderController *controllers.OrderController
|
|
DeliveriesController *controllers.DeliveriesController
|
|
UtilsController *controllers.UtilsController
|
|
TenantController *controllers.TenantController
|
|
PartnerController *controllers.PartnerController
|
|
CustomerController *controllers.CustomerController
|
|
StockRequestController *controllers.StockRequestController
|
|
CatalogueController *controllers.CatalogueController
|
|
PosController *controllers.PosController
|
|
LiveController *controllers.LiveController
|
|
CatalogueUploadController *controllers.CatalogueUploadController
|
|
ScanController *controllers.ScanController
|
|
|
|
// Tools is what Nearle Buddy is allowed to do.
|
|
//
|
|
// Held on the facade because the assistant is not a module with a
|
|
// repository of its own — it is a door onto the services already built
|
|
// here, and every tool handler calls one of them rather than the database.
|
|
Tools *tools.Registry
|
|
|
|
// Held so the NATS consumer can reach the ingest without going through
|
|
// HTTP. Unexported: everything else should use the controller.
|
|
posService services.PosService
|
|
}
|
|
|
|
// NewFacade wires up modules against the main (nearledb) connection.
|
|
// catalogueDB is a separate connection to the pgvector catalogue database;
|
|
// it may be nil if catalogue env vars are not configured, in which case
|
|
// catalogue endpoints will error at query time rather than at startup.
|
|
// embedder may be nil too: scan-to-order then matches on words alone.
|
|
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder) *Facade {
|
|
|
|
// User Module
|
|
userRepo := repositories.NewUserRepository(db)
|
|
userService := services.NewUserService(userRepo)
|
|
userController := controllers.NewUserController(userService)
|
|
|
|
// Catalogue Module (separate pgvector DB — never the main `db`). Built
|
|
// before the Product Module because ProductService depends on it to
|
|
// bridge catalogue imports into a tenant's own product catalogue.
|
|
catalogueRepo := repositories.NewCatalogueRepository(catalogueDB)
|
|
catalogueService := services.NewCatalogueService(catalogueRepo)
|
|
catalogueController := controllers.NewCatalogueController(catalogueService)
|
|
|
|
// Product Module
|
|
productRepo := repositories.NewProductRepository(db)
|
|
productService := services.NewProductService(productRepo, catalogueService)
|
|
productController := controllers.NewProductController(productService)
|
|
|
|
// Order Module
|
|
orderRepo := repositories.NewOrderRepository(db)
|
|
orderService := services.NewOrderService(orderRepo)
|
|
orderController := controllers.NewOrderController(orderService)
|
|
|
|
// Deliveries Module
|
|
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
|
deliveriesService := services.NewDeliveriesService(deliveriesRepo)
|
|
deliveriesController := controllers.NewDeliveriesController(deliveriesService)
|
|
|
|
// Utils Module
|
|
utilsRepo := repositories.NewUtilsRepository(db)
|
|
utilsService := services.NewUtilsService(utilsRepo)
|
|
utilsController := controllers.NewUtilsController(utilsService)
|
|
|
|
//Tenant Module
|
|
tenantRepo := repositories.NewTenantRepository(db)
|
|
tenantService := services.NewTenantService(tenantRepo)
|
|
tenantController := controllers.NewTenantController(tenantService)
|
|
|
|
//Partner Module
|
|
partnerRepo := repositories.NewPartnerRepository(db)
|
|
partnerService := services.NewPartnerService(partnerRepo)
|
|
partnerController := controllers.NewPartnerController(partnerService)
|
|
|
|
//Customer Module
|
|
customerRepo := repositories.NewCustomerRepository(db)
|
|
customerService := services.NewCustomerService(customerRepo)
|
|
customerController := controllers.NewCustomerController(customerService)
|
|
|
|
// Stock Request Module
|
|
stockRequestRepo := repositories.NewStockRequestRepository(db)
|
|
stockRequestService := services.NewStockRequestService(stockRequestRepo, productService)
|
|
stockRequestController := controllers.NewStockRequestController(stockRequestService)
|
|
|
|
// POS Module — ingest from the in-store terminals.
|
|
//
|
|
// Presence has no *gorm.DB: terminal health lives in Redis under a TTL, so
|
|
// a till that loses power ages out of the board by itself instead of
|
|
// leaving a Postgres row claiming it is online.
|
|
posRepo := repositories.NewPosRepository(db)
|
|
posPresence := repositories.NewPosPresenceRepository()
|
|
posService := services.NewPosService(posRepo, posPresence)
|
|
posController := controllers.NewPosController(posService)
|
|
|
|
// Shares the POS service purely for its outlet-ownership check — the
|
|
// stream itself reads no database and holds no state beyond its
|
|
// subscribers.
|
|
liveController := controllers.NewLiveController(posService)
|
|
|
|
// Catalogue Upload Module — our own receipt for every spreadsheet sent to
|
|
// the ingest service. Their host deletes an unreviewed drop after seven
|
|
// days and the batch id is the only credential for reading the result
|
|
// back, so the id has to be kept somewhere that outlives a browser tab.
|
|
catalogueUploadRepo := repositories.NewCatalogueUploadRepository(db)
|
|
catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo)
|
|
catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService)
|
|
|
|
// Scan Module — a label from the customer's camera to "buy it here".
|
|
// Reads both databases: the catalogue to recognise the product, nearledb
|
|
// for who the customer is and what their outlets have on the shelf.
|
|
scanRepo := repositories.NewScanRepository(db, catalogueDB)
|
|
scanService := services.NewScanService(scanRepo, embedder)
|
|
scanController := controllers.NewScanController(scanService)
|
|
|
|
// The assistant registry. Built last, because every tool it holds is a thin
|
|
// wrapper over a service constructed above.
|
|
//
|
|
// A registration error panics rather than being logged. A duplicate name or
|
|
// a tool with no description is a programming mistake, and a server that
|
|
// starts with a tool silently absent answers real questions with "I cannot
|
|
// do that" for a reason nobody can see from the outside.
|
|
toolRegistry := tools.New(tools.LogAudit{})
|
|
for _, tool := range []tools.Tool{
|
|
tools.StuckOrders(deliveriesService, nil),
|
|
} {
|
|
if err := toolRegistry.Register(tool); err != nil {
|
|
panic("assistant tools: " + err.Error())
|
|
}
|
|
}
|
|
|
|
return &Facade{
|
|
UserController: userController,
|
|
ProductController: productController,
|
|
OrderController: orderController,
|
|
DeliveriesController: deliveriesController,
|
|
UtilsController: utilsController,
|
|
TenantController: tenantController,
|
|
PartnerController: partnerController,
|
|
CustomerController: customerController,
|
|
StockRequestController: stockRequestController,
|
|
CatalogueController: catalogueController,
|
|
PosController: posController,
|
|
LiveController: liveController,
|
|
CatalogueUploadController: catalogueUploadController,
|
|
ScanController: scanController,
|
|
Tools: toolRegistry,
|
|
posService: posService,
|
|
}
|
|
}
|
|
|
|
// PosService exposes the ingest to callers outside the HTTP layer — the NATS
|
|
// consumer runs the same code path a POST does, so a bill arriving over MQTT
|
|
// and one arriving over HTTP cannot diverge.
|
|
func (f *Facade) PosService() services.PosService { return f.posService }
|