agent fix

This commit is contained in:
2026-09-24 12:36:33 +05:30
parent 294fb8ab93
commit bb14445e21
10 changed files with 661 additions and 15 deletions

View File

@@ -4,9 +4,9 @@
# credentials in `.env.production` were being baked into every image built # credentials in `.env.production` were being baked into every image built
# from this folder. The running container gets its environment from the # from this folder. The running container gets its environment from the
# platform (Dokploy / Kubernetes), never from a file. # platform (Dokploy / Kubernetes), never from a file.
.env
.env.*
!.env.example
.git .git
.claude .claude

91
config/assistant_test.go Normal file
View File

@@ -0,0 +1,91 @@
package config
import (
"strings"
"testing"
)
// Why the assistant is off.
//
// "Off" was the same answer for four different mistakes, and the only symptom
// was a disabled composer. Nobody could tell "we have not switched it on" from
// "somebody misspelled a variable" — which is how it stayed off for days with
// both of us guessing.
func TestAFullyConfiguredAssistantIsOn(t *testing.T) {
cfg := AssistantConfig{
Provider: "openai", BaseURL: "https://api.groq.com/openai/v1",
APIKey: "k", Balanced: "openai/gpt-oss-120b",
}
if !cfg.Enabled() {
t.Fatalf("a complete config was refused: %s", cfg.Why())
}
if cfg.Why() != "" {
t.Fatalf("an enabled assistant gave a reason: %q", cfg.Why())
}
}
func TestEachMissingPieceNamesItself(t *testing.T) {
for name, tc := range map[string]struct {
cfg AssistantConfig
says string
}{
// Nothing set at all names the MODEL, not the provider. The provider is
// derived from the model now, so an empty one is a consequence rather
// than a cause — and sending an operator to set ASSISTANT_PROVIDER, a
// variable they no longer need, while the one they actually missed goes
// unmentioned, is the same "off for four reasons" problem in new words.
"nothing set at all": {AssistantConfig{}, "ASSISTANT_MODEL"},
"no provider": {
AssistantConfig{Balanced: "m", APIKey: "k"}, "ASSISTANT_PROVIDER"},
"unknown provider": {
AssistantConfig{Provider: "anthropik", Balanced: "m", APIKey: "k"}, "not one this server speaks"},
"no model": {AssistantConfig{Provider: "openai", APIKey: "k"}, "ASSISTANT_MODEL"},
"no api key": {AssistantConfig{Provider: "openai", Balanced: "m", BaseURL: "https://api.groq.com/openai/v1"}, "ASSISTANT_API_KEY"},
} {
why := tc.cfg.Why()
if why == "" {
t.Fatalf("%s: reported as working", name)
}
if !strings.Contains(why, tc.says) {
t.Fatalf("%s: does not name the problem: %q", name, why)
}
}
}
func TestALocalModelNeedsNoKey(t *testing.T) {
// Ollama and LM Studio need no credential, and demanding one would refuse
// the setup a developer is most likely to have on their own machine.
for _, base := range []string{
"http://localhost:11434/v1",
"http://127.0.0.1:1234/v1",
"http://host.docker.internal:11434/v1",
} {
cfg := AssistantConfig{Provider: "openai", BaseURL: base, Balanced: "llama3"}
if !cfg.Enabled() {
t.Fatalf("%s was refused without a key: %s", base, cfg.Why())
}
}
}
func TestAHostedModelWithoutAKeyIsRefusedBeforeItFailsAtRuntime(t *testing.T) {
// Otherwise the first question a shopkeeper asks comes back as a 401 from
// the provider, which reads as the assistant being broken rather than as a
// variable nobody set.
cfg := AssistantConfig{Provider: "openai", BaseURL: "https://api.groq.com/openai/v1", Balanced: "m"}
if cfg.Enabled() {
t.Fatal("a hosted provider with no key reported as ready")
}
}
func TestTheTierFallbackDoesNotHideAMissingModel(t *testing.T) {
// `fast` and `deep` fall back to balanced, so a config with only those two
// set has no model at all for the default tier.
cfg := AssistantConfig{Provider: "openai", APIKey: "k", Fast: "small", Deep: "big"}
if cfg.Enabled() {
t.Fatal("an assistant with no balanced model reported as ready")
}
if cfg.ModelFor("fast") != "" && cfg.ModelFor("balanced") != "" {
t.Fatal("balanced resolved to something despite being unset")
}
}

View File

@@ -172,7 +172,60 @@ type AssistantConfig struct {
Deep string Deep string
} }
func (a AssistantConfig) Enabled() bool { return a.Provider != "" && a.Balanced != "" } func (a AssistantConfig) Enabled() bool { return a.Why() == "" }
// Why says what is missing, or "" when the assistant can run.
//
// A sentence rather than a bool, because "off" is the same answer for four
// different mistakes: no provider, no model, no key, a provider nobody
// recognises. Without this the only symptom is a disabled composer, and the
// difference between "we have not switched it on" and "somebody misspelled a
// variable" is invisible from the outside — which is exactly where this was
// stuck.
// The model is reported before the provider, and that order matters. Since
// `assistantProvider` derives the provider from the model, an empty provider
// means the model is empty too — and naming ASSISTANT_PROVIDER first would send
// an operator to set a variable they no longer need, while the one they
// actually missed went unmentioned.
func (a AssistantConfig) Why() string {
if a.Balanced == "" {
return "ASSISTANT_MODEL is not set; give it the provider's model name, " +
"for example openai/gpt-oss-120b"
}
switch a.Provider {
case "openai", "groq", "ollama", "together", "compatible":
case "":
// Not reachable through Load, which derives it. Reachable when
// something builds this struct by hand, and silence would be worse.
return "ASSISTANT_PROVIDER is not set and could not be derived"
default:
return "ASSISTANT_PROVIDER is " + a.Provider + ", which is not one this server speaks"
}
// A local provider needs no credential; a hosted one always does, and a
// missing key otherwise surfaces as a 401 from the provider on the first
// question rather than as a configuration problem.
if a.APIKey == "" && !isLocalEndpoint(a.BaseURL) {
where := a.BaseURL
if where == "" {
// Empty means the OpenAI default, which is emphatically not local.
// "and is not a local endpoint" is how that read before.
where = "the default https://api.openai.com/v1"
}
return "ASSISTANT_API_KEY is not set, and " + where + " is not a local endpoint"
}
return ""
}
// isLocalEndpoint reports whether a base URL is something running beside us.
//
// Ollama and LM Studio need no key, and demanding one would refuse the setup a
// developer is most likely to have on their own machine.
func isLocalEndpoint(baseURL string) bool {
url := strings.ToLower(baseURL)
return strings.Contains(url, "localhost") ||
strings.Contains(url, "127.0.0.1") ||
strings.Contains(url, "host.docker.internal")
}
// ModelFor resolves a tier to a model name, falling back rather than failing. // ModelFor resolves a tier to a model name, falling back rather than failing.
// //
@@ -192,6 +245,22 @@ func (a AssistantConfig) ModelFor(tier string) string {
return a.Balanced return a.Balanced
} }
// assistantProvider reads the provider, defaulting to the one shape this
// server speaks.
//
// A deployment that names a model and a key has said what it wants; making it
// also name a protocol it has no choice about is a variable that exists only to
// be forgotten.
func assistantProvider() string {
if named := strings.ToLower(strings.TrimSpace(env("ASSISTANT_PROVIDER", ""))); named != "" {
return named
}
if strings.TrimSpace(env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", ""))) != "" {
return "openai"
}
return ""
}
// IsProduction is true under APP_ENV=production. // IsProduction is true under APP_ENV=production.
func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction } func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction }
@@ -249,7 +318,11 @@ func Load() (*Config, error) {
}, },
Assistant: AssistantConfig{ Assistant: AssistantConfig{
Provider: strings.ToLower(env("ASSISTANT_PROVIDER", "")), // Defaults to "openai" when a model is named, because every endpoint
// this speaks is OpenAI-compatible and the base URL is what actually
// distinguishes them. One less variable to set, and one less way to
// have the assistant silently off.
Provider: assistantProvider(),
BaseURL: env("ASSISTANT_BASE_URL", ""), BaseURL: env("ASSISTANT_BASE_URL", ""),
APIKey: env("ASSISTANT_API_KEY", ""), APIKey: env("ASSISTANT_API_KEY", ""),
Fast: env("ASSISTANT_MODEL_FAST", ""), Fast: env("ASSISTANT_MODEL_FAST", ""),

View File

@@ -56,9 +56,16 @@ type assistantAskRequest struct {
// built, and this is what finally answers that question at runtime rather than // built, and this is what finally answers that question at runtime rather than
// at build time. // at build time.
func (ctl *AssistantController) Status(c *fiber.Ctx) error { func (ctl *AssistantController) Status(c *fiber.Ctx) error {
details := fiber.Map{"available": ctl.assistant.Available()}
// Named "reason" rather than "error": not having an assistant is a
// deployment choice, and the same field answers "we have not switched it
// on" and "somebody misspelled a variable" — which are the two states that
// looked identical from outside.
if why := ctl.assistant.Unavailable(); why != "" {
details["reason"] = why
}
return c.Status(http.StatusOK).JSON(fiber.Map{ return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Success", "code": http.StatusOK, "status": true, "message": "Success", "details": details,
"details": fiber.Map{"available": ctl.assistant.Available()},
}) })
} }

View File

@@ -0,0 +1,439 @@
package controllers
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"nearle/config"
"nearle/middleware"
"nearle/models"
"nearle/services"
"nearle/services/tools"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// Nearle Buddy over HTTP, through the guard, as the console reaches it.
//
// Everything else tests one layer. The service tests call `Ask` directly with a
// caller already built; the live tests talk to a real model but never touch a
// route. Neither would notice the thing most likely to break on a deploy: the
// seam where a session token becomes a tool caller.
//
// That seam has four parts, and a mistake in any one of them produces a console
// showing an empty panel and a server logging nothing —
//
// the route sits under /v1/web, so WebAuth runs at all
// WebAuth verifies the token and parks the claims
// callerFrom reads those claims rather than the request body
// the answer comes back inside `details`, where the console's client looks
//
// No database: every tool is handed a fake, so this runs in CI beside the unit
// tests. The ones that need a model skip without a key.
const testSecret = "a-test-signing-secret-of-ample-length"
var testCaller = utils.WebClaims{Userid: 904, Tenantid: 1147}
// ── the shop these tests run against ────────────────────────────────────────
type fakeShop struct {
deliveries []models.Deliveryinfo
requests []models.StockRequest
// approved records what reached the write half, so the approval test can
// assert the change happened rather than that it was described.
approved []string
}
func (f *fakeShop) GetDeliveries(models.DeliveryQuery) []models.Deliveryinfo { return f.deliveries }
func (f *fakeShop) GetStockRequests(tenantID, _ int, status, _ string, _, _ int) ([]models.StockRequest, error) {
// Honours the tenant on purpose. A fake that returned rows to anybody would
// let an ownership bug pass this test.
if tenantID != testCaller.Tenantid || !strings.EqualFold(status, "Pending") {
return nil, nil
}
return f.requests, nil
}
func (f *fakeShop) UpdateStockRequest(requestID int, status string) error {
f.approved = append(f.approved, status+" #"+strconv.Itoa(requestID))
for i := range f.requests {
if f.requests[i].Requestid == requestID {
// Drops out of the pending list, as the real update does. Without
// this, approving the same card twice would succeed twice.
f.requests = append(f.requests[:i], f.requests[i+1:]...)
break
}
}
return nil
}
// The tools these tests do not exercise still have to exist, because the
// shipped agents name them and LoadAgents refuses an agent naming a tool that
// is absent. An empty answer is the honest fake: a shop with nothing to report.
func (f *fakeShop) GetLocationOrderSummary(int) ([]models.Ordersummarylocation, error) {
return nil, nil
}
func (f *fakeShop) GetProductStocks(string, string) ([]models.Productstocks, error) {
return nil, nil
}
func (f *fakeShop) LocationHealth(context.Context, string) ([]map[string]string, error) {
return nil, nil
}
func (f *fakeShop) GetRevenueSummary(int, int, string, string) (*models.TenantRevenueSummary, error) {
return &models.TenantRevenueSummary{}, nil
}
func (f *fakeShop) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) {
return &models.PosSalesSummary{}, nil
}
func newShop() *fakeShop {
now := time.Now()
stamp := func(minutesAgo int) string {
return now.Add(-time.Duration(minutesAgo) * time.Minute).Format("2006-01-02 15:04:05")
}
return &fakeShop{
deliveries: []models.Deliveryinfo{
{Deliveryid: 4412, Orderid: "ORD-4412", Orderstatus: "pending", Assigntime: stamp(41),
Ridername: "Varun", Locationname: "R Mart"},
{Deliveryid: 4421, Orderid: "ORD-4421", Orderstatus: "delivered", Assigntime: stamp(200)},
},
requests: []models.StockRequest{{
Requestid: 41, Productname: "Sona Masoori rice 25kg", Qty: 12,
Locationname: "R Mart", Status: "Pending", Created: now.Add(-36 * time.Hour),
}},
}
}
// ── the server, wired the way production wires it ───────────────────────────
func buildApp(t *testing.T, chat utils.Chat) (*fiber.App, *fakeShop) {
t.Helper()
t.Setenv("POS_TOKEN_SECRET", testSecret)
shop := newShop()
corpus, err := tools.LoadHelp()
if err != nil {
t.Fatalf("help corpus: %v", err)
}
registry := tools.New(tools.DiscardAudit{})
for _, tool := range []tools.Tool{
tools.StuckOrders(shop, nil),
tools.DeliveryProgress(shop),
tools.BranchPerformance(shop),
tools.PendingApprovals(shop, nil),
tools.LowStock(shop),
tools.TillsNotSyncing(shop),
tools.SalesByChannel(shop, shop, nil),
tools.Help(corpus),
tools.ApproveStockRequest(shop, shop),
} {
if err := registry.Register(tool); err != nil {
t.Fatalf("registering %s: %v", tool.Name, err)
}
}
// The shipped agent definitions, not a hand-built stand-in. A typo in
// agents/inventory.yaml should fail here rather than on deploy.
agents, err := services.LoadAgents("", registry.Has)
if err != nil {
t.Fatalf("agents: %v", err)
}
assistant := services.NewAssistantService(registry, chat, agents)
// Mirrors facade.NewFacade: with no model, the reason the config gives is
// threaded through to the service so /status can name the missing variable.
// Built the same way here, or this would assert a string production never
// produces.
if setter, ok := assistant.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
setter.SetUnavailableReason(config.AssistantConfig{}.Why())
}
controller := NewAssistantController(assistant)
app := fiber.New()
// nil is the branch-ownership checker, consulted only when a request names
// a branch. The assistant's body names none — that is the design — so
// nothing here can reach it.
app.Use(middleware.WebAuth(nil))
web := app.Group("/live/api/v1/web")
web.Get("/assistant/status", controller.Status)
web.Post("/assistant/ask", controller.Ask)
web.Post("/assistant/approve", controller.Approve)
return app, shop
}
func webSession(t *testing.T) string {
t.Helper()
token, _, err := utils.MintWebToken(testCaller, time.Now())
if err != nil {
t.Fatalf("minting a session: %v", err)
}
return token
}
// envelope is the shape every Fiesta handler answers with, and the shape the
// console's client unwraps. Asserting on it rather than on the Go struct is the
// point: a controller returning the answer at the top level would pass a
// service-level test and hand the console `undefined`.
type envelope struct {
Code int `json:"code"`
Status bool `json:"status"`
Message string `json:"message"`
Details services.AssistantAnswer `json:"details"`
}
const (
statusPath = "/live/api/v1/web/assistant/status"
askPath = "/live/api/v1/web/assistant/ask"
approvePath = "/live/api/v1/web/assistant/approve"
)
func post(t *testing.T, app *fiber.App, path, token, body string) (int, envelope, string) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
raw, _ := io.ReadAll(resp.Body)
var out envelope
_ = json.Unmarshal(raw, &out)
return resp.StatusCode, out, string(raw)
}
func quote(s string) string {
out, _ := json.Marshal(s)
return string(out)
}
// ── the guard ───────────────────────────────────────────────────────────────
func TestAnUntokenedQuestionIsRefusedOverHTTP(t *testing.T) {
// WEB_AUTH_REQUIRED defaults on now, so the middleware turns this away
// before the controller sees it. Either refusal is correct; what must never
// happen is an answer.
app, _ := buildApp(t, nil)
status, _, body := post(t, app, askPath, "", `{"agent":"orders","question":"what is stuck?"}`)
if status == fiber.StatusOK {
t.Fatalf("an untokened question was answered: %s", body)
}
if status != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", status, body)
}
}
func TestATamperedTokenIsRefusedOverHTTP(t *testing.T) {
app, _ := buildApp(t, nil)
// Three characters at the end — the edit somebody would actually attempt.
broken := webSession(t)
broken = broken[:len(broken)-3] + "AAA"
status, _, body := post(t, app, askPath, broken, `{"question":"what is stuck?"}`)
if status != fiber.StatusUnauthorized {
t.Fatalf("a tampered session was not refused: %d %s", status, body)
}
}
func TestStatusNamesTheMissingVariable(t *testing.T) {
// Why the field exists: "available: false" alone is the same answer for "we
// have not switched it on" and "somebody misspelled a variable", and those
// need different actions from whoever is looking.
app, _ := buildApp(t, nil)
req := httptest.NewRequest("GET", statusPath, nil)
req.Header.Set("Authorization", "Bearer "+webSession(t))
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("status: %v", err)
}
raw, _ := io.ReadAll(resp.Body)
var out struct {
Details struct {
Available bool `json:"available"`
Reason string `json:"reason"`
} `json:"details"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("status is not the envelope the console unwraps: %s", raw)
}
if out.Details.Available {
t.Fatal("reported available with no model configured")
}
if out.Details.Reason == "" {
t.Fatalf("said no without saying why: %s", raw)
}
// Names the variable, not merely the symptom. "no assistant model is
// configured" is what the service says on its own, and it is the answer
// that left this switched off without anybody being able to tell which
// variable was wrong.
if !strings.Contains(out.Details.Reason, "ASSISTANT_") {
t.Fatalf("the reason names no variable to go and set: %q", out.Details.Reason)
}
t.Logf("reason: %s", out.Details.Reason)
}
// ── the live path ───────────────────────────────────────────────────────────
func liveHTTPChat(t *testing.T) utils.Chat {
t.Helper()
// Defaults exactly as production does, so this proves three variables are
// enough rather than working around the question.
provider := strings.ToLower(strings.TrimSpace(os.Getenv("ASSISTANT_PROVIDER")))
model := strings.TrimSpace(os.Getenv("ASSISTANT_MODEL"))
if provider == "" && model != "" {
provider = "openai"
}
cfg := config.AssistantConfig{
Provider: provider,
BaseURL: os.Getenv("ASSISTANT_BASE_URL"),
APIKey: os.Getenv("ASSISTANT_API_KEY"),
Balanced: model,
}
if !cfg.Enabled() {
t.Skipf("no model configured: %s", cfg.Why())
}
chat, err := utils.NewChat(cfg)
if err != nil || chat == nil {
t.Skipf("gateway not built: %v", err)
}
return chat
}
func TestLiveAQuestionAnswersThroughTheWholeStack(t *testing.T) {
app, _ := buildApp(t, liveHTTPChat(t))
status, out, body := post(t, app, askPath, webSession(t),
`{"agent":"orders","question":"Which orders are stuck?"}`)
if status != fiber.StatusOK {
t.Fatalf("HTTP %d: %s", status, body)
}
if !out.Status {
t.Fatalf("envelope says failure: %s", out.Message)
}
// Inside `details`, where the console's client reads. A correct answer at
// the top level is still a broken console.
if strings.TrimSpace(out.Details.Reply) == "" {
t.Fatalf("no reply in details: %s", body)
}
if len(out.Details.Used) == 0 {
t.Fatalf("answered without running a tool — it invented it: %s", out.Details.Reply)
}
t.Logf("used: %+v", out.Details.Used)
t.Logf("reply: %s", out.Details.Reply)
}
func TestLiveTheAnswerIsScopedToTheSessionsTenant(t *testing.T) {
// The claim the whole design rests on. The request body carries no tenant,
// so rows can only be reached through the token — and a session whose shop
// has nothing pending must not be handed a list.
app, shop := buildApp(t, liveHTTPChat(t))
shop.requests = nil
status, out, body := post(t, app, askPath, webSession(t),
`{"agent":"inventory","question":"What stock requests are waiting for approval?"}`)
if status != fiber.StatusOK {
t.Fatalf("HTTP %d: %s", status, body)
}
if strings.Contains(out.Details.Reply, "Sona Masoori") {
t.Fatalf("named a row this session cannot see: %s", out.Details.Reply)
}
t.Logf("reply: %s", out.Details.Reply)
}
// ── the approval card, end to end ───────────────────────────────────────────
func TestLiveAnApprovalCardRoundTripsAndWrites(t *testing.T) {
// The one path that has never run whole. The model proposes, the card comes
// back signed, the console sends it in unchanged, and only then does
// anything change. Each half has unit tests; this is the join.
app, shop := buildApp(t, liveHTTPChat(t))
token := webSession(t)
status, out, body := post(t, app, askPath, token,
`{"agent":"inventory","question":"Approve stock request 41."}`)
if status != fiber.StatusOK {
t.Fatalf("asking: HTTP %d: %s", status, body)
}
if out.Details.Awaiting == nil {
t.Fatalf("no approval card came back — nothing to press: %s", out.Details.Reply)
}
card := out.Details.Awaiting.Card
t.Logf("card: %s", out.Details.Awaiting.Summary)
// Nothing may have happened yet. A write at proposal time is the failure
// the whole two-step exists to prevent.
if len(shop.approved) != 0 {
t.Fatalf("the change was made before anybody agreed to it: %v", shop.approved)
}
status, done, body := post(t, app, approvePath, token,
`{"agent":"inventory","card":`+quote(card)+`}`)
if status != fiber.StatusOK {
t.Fatalf("approving: HTTP %d: %s", status, body)
}
if len(shop.approved) != 1 || shop.approved[0] != "Approved #41" {
t.Fatalf("the write did not reach the service: %v", shop.approved)
}
t.Logf("after approval: %s", done.Details.Reply)
// Pressing twice must not approve twice. The card still verifies; the row
// is no longer pending, and the re-check at execute time is what notices.
status, _, _ = post(t, app, approvePath, token,
`{"agent":"inventory","card":`+quote(card)+`}`)
if status == fiber.StatusOK {
t.Fatal("the same card approved the same request twice")
}
if len(shop.approved) != 1 {
t.Fatalf("a second write got through: %v", shop.approved)
}
}
func TestAForgedCardIsRefused(t *testing.T) {
// No model needed: a card that does not verify must be refused before
// anything reads what it claims.
app, shop := buildApp(t, nil)
status, _, body := post(t, app, approvePath, webSession(t),
`{"agent":"inventory","card":"w1.bm90LWEtcmVhbC1jYXJk.c2lnbmF0dXJl"}`)
if status == fiber.StatusOK {
t.Fatalf("a forged card was accepted: %s", body)
}
if len(shop.approved) != 0 {
t.Fatalf("a forged card changed something: %v", shop.approved)
}
}

View File

@@ -47,7 +47,7 @@ type Facade struct {
// it may be nil if catalogue env vars are not configured, in which case // 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. // catalogue endpoints will error at query time rather than at startup.
// embedder may be nil too: scan-to-order then matches on words alone. // 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, agentsDir string) *Facade { func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utils.Chat, agentsDir, assistantWhy string) *Facade {
// User Module // User Module
userRepo := repositories.NewUserRepository(db) userRepo := repositories.NewUserRepository(db)
@@ -182,6 +182,11 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
log.Printf("assistant: %d agents loaded %v", len(agents), services.AgentNames(agents)) log.Printf("assistant: %d agents loaded %v", len(agents), services.AgentNames(agents))
assistantService := services.NewAssistantService(toolRegistry, chat, agents) assistantService := services.NewAssistantService(toolRegistry, chat, agents)
// Why there is no model, if there is not. Passed through so /assistant/status
// can name the missing variable instead of just saying no.
if setter, ok := assistantService.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
setter.SetUnavailableReason(assistantWhy)
}
assistantController := controllers.NewAssistantController(assistantService) assistantController := controllers.NewAssistantController(assistantService)
// The second door. Same registry, same agents, same session — see // The second door. Same registry, same agents, same session — see

View File

@@ -434,7 +434,7 @@ func main() {
log.Fatal("assistant provider:", err) log.Fatal("assistant provider:", err)
} }
if chat == nil { if chat == nil {
log.Println("assistant: ASSISTANT_PROVIDER not set, Nearle Buddy answers no typed questions") log.Printf("assistant: OFF — %s", cfg.Assistant.Why())
} else { } else {
log.Printf("assistant: %s, balanced tier is %s", cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced)) log.Printf("assistant: %s, balanced tier is %s", cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced))
} }
@@ -442,7 +442,7 @@ func main() {
// ASSISTANT_AGENTS_DIR replaces the compiled-in agent definitions wholesale. // ASSISTANT_AGENTS_DIR replaces the compiled-in agent definitions wholesale.
// Empty uses the embedded ones, so a deployment cannot be broken by a missing // Empty uses the embedded ones, so a deployment cannot be broken by a missing
// directory. // directory.
f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat, os.Getenv("ASSISTANT_AGENTS_DIR")) f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat, os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why())
routes.RegisterRoutes(app, f) routes.RegisterRoutes(app, f)

View File

@@ -42,7 +42,7 @@ func testFacade(t *testing.T) *facade.Facade {
// No database, no catalogue, no embedder, no model. A deployment with none // No database, no catalogue, no embedder, no model. A deployment with none
// of those must still boot and say what it is missing, rather than failing // of those must still boot and say what it is missing, rather than failing
// somewhere the operator cannot see. // somewhere the operator cannot see.
return facade.NewFacade(nil, nil, nil, nil, "") return facade.NewFacade(nil, nil, nil, nil, "", "no model in tests")
} }
func TestTheServerCanBeBuilt(t *testing.T) { func TestTheServerCanBeBuilt(t *testing.T) {

View File

@@ -41,14 +41,25 @@ import (
func liveChat(t *testing.T) (utils.Chat, string) { func liveChat(t *testing.T) (utils.Chat, string) {
t.Helper() t.Helper()
// Provider defaults exactly as production does, so this exercises the
// defaulting rather than working around it. The first version read the
// variable straight and skipped silently the moment `ASSISTANT_PROVIDER`
// was left unset — which is precisely the configuration this is meant to
// prove works.
provider := strings.ToLower(strings.TrimSpace(os.Getenv("ASSISTANT_PROVIDER")))
model := os.Getenv("ASSISTANT_MODEL")
if provider == "" && strings.TrimSpace(model) != "" {
provider = "openai"
}
cfg := config.AssistantConfig{ cfg := config.AssistantConfig{
Provider: strings.ToLower(strings.TrimSpace(os.Getenv("ASSISTANT_PROVIDER"))), Provider: provider,
BaseURL: os.Getenv("ASSISTANT_BASE_URL"), BaseURL: os.Getenv("ASSISTANT_BASE_URL"),
APIKey: os.Getenv("ASSISTANT_API_KEY"), APIKey: os.Getenv("ASSISTANT_API_KEY"),
Balanced: os.Getenv("ASSISTANT_MODEL"), Balanced: model,
} }
if cfg.APIKey == "" || !cfg.Enabled() { if !cfg.Enabled() {
t.Skip("no ASSISTANT_* provider configured; skipping the live model test") t.Skipf("no model configured, skipping the live test: %s", cfg.Why())
} }
chat, err := utils.NewChat(cfg) chat, err := utils.NewChat(cfg)

View File

@@ -97,12 +97,19 @@ type AssistantService interface {
Approve(ctx context.Context, agentName, card string, caller tools.Caller) (AssistantAnswer, error) Approve(ctx context.Context, agentName, card string, caller tools.Caller) (AssistantAnswer, error)
// Available reports whether typed questions work at all here. // Available reports whether typed questions work at all here.
Available() bool Available() bool
// Unavailable says WHY not, or "" when it is available. A disabled composer
// with no reason is indistinguishable from a misspelled variable, which is
// how this stayed off without anybody being able to tell.
Unavailable() string
} }
type assistantService struct { type assistantService struct {
registry *tools.Registry registry *tools.Registry
chat utils.Chat chat utils.Chat
agents map[string]Agent agents map[string]Agent
// Why the model is absent, from config. Carried rather than recomputed so
// the answer the endpoint gives is the one the server actually started with.
why string
// How fast one person may ask. Only questions are limited — approving a // How fast one person may ask. Only questions are limited — approving a
// change the person has already read costs nothing and must not be the call // change the person has already read costs nothing and must not be the call
// that gets refused. // that gets refused.
@@ -120,6 +127,19 @@ func NewAssistantService(registry *tools.Registry, chat utils.Chat, agents map[s
func (s *assistantService) Available() bool { return s.chat != nil } func (s *assistantService) Available() bool { return s.chat != nil }
func (s *assistantService) Unavailable() string {
if s.chat != nil {
return ""
}
if s.why != "" {
return s.why
}
return "no assistant model is configured"
}
// SetUnavailableReason records why there is no model, for the status endpoint.
func (s *assistantService) SetUnavailableReason(why string) { s.why = why }
func (s *assistantService) Approve(ctx context.Context, agentName, card string, caller tools.Caller) (AssistantAnswer, error) { func (s *assistantService) Approve(ctx context.Context, agentName, card string, caller tools.Caller) (AssistantAnswer, error) {
agent, known := s.agents[agentName] agent, known := s.agents[agentName]
if !known { if !known {