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>
180 lines
8.0 KiB
Go
180 lines
8.0 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
|
|
AssistantController *controllers.AssistantController
|
|
|
|
// 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, chat utils.Chat) *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())
|
|
}
|
|
}
|
|
|
|
// Nearle Buddy. `chat` may be nil — a deployment with no model configured
|
|
// still gets the registry and the endpoint, and the endpoint answers "not
|
|
// switched on here" rather than a 500. The tools themselves are ordinary
|
|
// Go functions and work either way; only turning a sentence into a tool
|
|
// call needs a model.
|
|
assistantService := services.NewAssistantService(toolRegistry, chat, nil)
|
|
assistantController := controllers.NewAssistantController(assistantService)
|
|
|
|
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,
|
|
AssistantController: assistantController,
|
|
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 }
|