- Implemented new gRPC service `AIService` in `proto/ai/v1/ai.proto` for handling natural language queries. - Generated Go code for the gRPC service and messages in `gen/ai/v1/`. - Created `services/ai-gateway/` directory structure with necessary files for the service. - Added configuration loading and structured logging. - Implemented domain logic for intent parsing and interaction with Home Assistant. - Established outbound adapters for Ollama and Home Assistant with mTLS support. - Updated `go.work` to include the new service and maintain existing dependencies. - Modified `discord-bot` to use the new `ai-gateway` for AI interactions. - Added deployment manifest for Kubernetes and CI/CD configuration for building and deploying the service.
167 lines
5.2 KiB
Go
167 lines
5.2 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.nik4nao.com/nik/home-services/ai-gateway/internal/core/domain"
|
|
"gitea.nik4nao.com/nik/home-services/ai-gateway/internal/core/ports/driven"
|
|
)
|
|
|
|
type fakeLLM struct {
|
|
generate func(context.Context, string) (string, error)
|
|
}
|
|
|
|
func (f *fakeLLM) Generate(ctx context.Context, prompt string) (string, error) {
|
|
return f.generate(ctx, prompt)
|
|
}
|
|
|
|
type fakeHA struct {
|
|
lights []driven.Light
|
|
listErr error
|
|
turnOnErr error
|
|
turnOffErr error
|
|
lastTurnOnID string
|
|
lastTurnOffID string
|
|
lastTurnParams map[string]string
|
|
listCalls int
|
|
}
|
|
|
|
func (f *fakeHA) TurnOnLight(ctx context.Context, entity string, params map[string]string) error {
|
|
f.lastTurnOnID = entity
|
|
f.lastTurnParams = params
|
|
return f.turnOnErr
|
|
}
|
|
|
|
func (f *fakeHA) TurnOffLight(ctx context.Context, entity string) error {
|
|
f.lastTurnOffID = entity
|
|
return f.turnOffErr
|
|
}
|
|
|
|
func (f *fakeHA) ListLights(ctx context.Context) ([]driven.Light, error) {
|
|
f.listCalls++
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
return append([]driven.Light(nil), f.lights...), nil
|
|
}
|
|
|
|
func TestQueryAppTurnOnLight(t *testing.T) {
|
|
ha := &fakeHA{lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen", State: "off"}}}
|
|
cache := domain.NewLightCache(time.Hour, ha.ListLights)
|
|
app := NewQueryApp(&fakeLLM{
|
|
generate: func(ctx context.Context, prompt string) (string, error) {
|
|
return `{"intent":"turn_on_light","entity":"Kitchen","params":{"brightness":"80"},"reply":"Turning on Kitchen."}`, nil
|
|
},
|
|
}, ha, cache, slog.Default())
|
|
|
|
got, err := app.Query(context.Background(), "turn on kitchen")
|
|
if err != nil {
|
|
t.Fatalf("Query() error = %v", err)
|
|
}
|
|
if got.Intent != domain.IntentTurnOnLight || !got.ActionTaken || got.Reply != "Turning on Kitchen." {
|
|
t.Fatalf("Query() = %+v", got)
|
|
}
|
|
if ha.lastTurnOnID != "light.kitchen" {
|
|
t.Fatalf("TurnOnLight entity = %q", ha.lastTurnOnID)
|
|
}
|
|
if !reflect.DeepEqual(ha.lastTurnParams, map[string]string{"brightness": "80"}) {
|
|
t.Fatalf("TurnOnLight params = %#v", ha.lastTurnParams)
|
|
}
|
|
}
|
|
|
|
func TestQueryAppInvalidJSON(t *testing.T) {
|
|
ha := &fakeHA{lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen", State: "off"}}}
|
|
app := NewQueryApp(&fakeLLM{
|
|
generate: func(ctx context.Context, prompt string) (string, error) {
|
|
return `not-json`, nil
|
|
},
|
|
}, ha, domain.NewLightCache(time.Hour, ha.ListLights), slog.Default())
|
|
|
|
got, err := app.Query(context.Background(), "turn on kitchen")
|
|
if err != nil {
|
|
t.Fatalf("Query() error = %v", err)
|
|
}
|
|
if got.Reply != "I didn't understand that." || got.ActionTaken {
|
|
t.Fatalf("Query() = %+v", got)
|
|
}
|
|
if ha.lastTurnOnID != "" {
|
|
t.Fatalf("expected no HA call, got %q", ha.lastTurnOnID)
|
|
}
|
|
}
|
|
|
|
func TestQueryAppIntentNone(t *testing.T) {
|
|
ha := &fakeHA{lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen", State: "off"}}}
|
|
app := NewQueryApp(&fakeLLM{
|
|
generate: func(ctx context.Context, prompt string) (string, error) {
|
|
return `{"intent":"none","entity":"","params":{},"reply":"Hello there."}`, nil
|
|
},
|
|
}, ha, domain.NewLightCache(time.Hour, ha.ListLights), slog.Default())
|
|
|
|
got, err := app.Query(context.Background(), "hello")
|
|
if err != nil {
|
|
t.Fatalf("Query() error = %v", err)
|
|
}
|
|
if got.Reply != "Hello there." || got.ActionTaken {
|
|
t.Fatalf("Query() = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestQueryAppHAFailure(t *testing.T) {
|
|
ha := &fakeHA{
|
|
lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen", State: "off"}},
|
|
turnOnErr: errors.New("boom"),
|
|
}
|
|
app := NewQueryApp(&fakeLLM{
|
|
generate: func(ctx context.Context, prompt string) (string, error) {
|
|
return `{"intent":"turn_on_light","entity":"light.kitchen","params":{},"reply":"Turning on Kitchen."}`, nil
|
|
},
|
|
}, ha, domain.NewLightCache(time.Hour, ha.ListLights), slog.Default())
|
|
|
|
got, err := app.Query(context.Background(), "turn on kitchen")
|
|
if err != nil {
|
|
t.Fatalf("Query() error = %v", err)
|
|
}
|
|
if got.Reply != "I couldn't reach Home Assistant right now." || got.ActionTaken {
|
|
t.Fatalf("Query() = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestQueryAppListLights(t *testing.T) {
|
|
ha := &fakeHA{lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen", State: "on"}}}
|
|
app := NewQueryApp(&fakeLLM{
|
|
generate: func(ctx context.Context, prompt string) (string, error) {
|
|
return `{"intent":"list_lights","entity":"","params":{},"reply":""}`, nil
|
|
},
|
|
}, ha, domain.NewLightCache(time.Hour, ha.ListLights), slog.Default())
|
|
|
|
got, err := app.Query(context.Background(), "what lights exist")
|
|
if err != nil {
|
|
t.Fatalf("Query() error = %v", err)
|
|
}
|
|
want := "Known lights:\n- Kitchen (light.kitchen) [on]"
|
|
if got.Reply != want || got.ActionTaken {
|
|
t.Fatalf("Query() = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestLightCacheRefreshAfterTTL(t *testing.T) {
|
|
ha := &fakeHA{lights: []driven.Light{{EntityID: "light.kitchen", FriendlyName: "Kitchen", State: "off"}}}
|
|
cache := domain.NewLightCache(10*time.Millisecond, ha.ListLights)
|
|
|
|
if _, err := cache.Get(context.Background()); err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
if _, err := cache.Get(context.Background()); err != nil {
|
|
t.Fatalf("Get() error = %v", err)
|
|
}
|
|
if ha.listCalls < 2 {
|
|
t.Fatalf("ListLights calls = %d, want at least 2", ha.listCalls)
|
|
}
|
|
}
|