Nik Afiq 520f5d1ffb
Some checks failed
CI / build-ai-gateway (push) Has been cancelled
CI / build-ha-gateway (push) Has been cancelled
CI / build-discord-bot (push) Has been cancelled
CI / test (push) Has been cancelled
feat: add ai-gateway microservice with gRPC API for AI logic
- 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.
2026-04-21 21:52:28 +09:00

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)
}
}