package app import ( "context" "encoding/json" "fmt" "log/slog" "slices" "strconv" "strings" "gitea.nik4nao.com/nik/home-services/ai-gateway/internal/core/domain" "gitea.nik4nao.com/nik/home-services/ai-gateway/internal/core/ports/driven" ) // QueryResult is the app-layer response mapped onto the gRPC API. type QueryResult struct { Reply string Intent string ActionTaken bool } // QueryApp orchestrates one AI query request. type QueryApp struct { llm driven.LLMClient ha driven.HAClient cache *domain.LightCache log *slog.Logger } // NewQueryApp constructs the AI query application service. func NewQueryApp(llm driven.LLMClient, ha driven.HAClient, cache *domain.LightCache, log *slog.Logger) *QueryApp { return &QueryApp{llm: llm, ha: ha, cache: cache, log: log} } // Query runs the full intent parsing and dispatch flow for one user request. func (a *QueryApp) Query(ctx context.Context, text string) (QueryResult, error) { lights, err := a.cache.Get(ctx) if err != nil { a.log.Error("light cache refresh failed", "err", err) return QueryResult{ Reply: "I couldn't reach Home Assistant right now.", ActionTaken: false, }, nil } prompt := domain.BuildPrompt(text, promptLightLines(lights)) raw, err := a.llm.Generate(ctx, prompt) if err != nil { return QueryResult{}, err } var intent domain.Intent if err := json.Unmarshal([]byte(raw), &intent); err != nil { a.log.Warn("llm returned invalid json", "text", text, "raw_output", raw) return QueryResult{ Reply: "I didn't understand that.", Intent: domain.IntentNone, ActionTaken: false, }, nil } switch intent.Name { case domain.IntentTurnOnLight: entityID, ok := resolveLightEntity(intent.Entity, lights) if !ok { return QueryResult{Reply: "I couldn't find that light.", Intent: intent.Name}, nil } params, err := ParseLightParams(intent.Params) if err != nil { return QueryResult{ Reply: "I couldn't understand the light settings.", Intent: intent.Name, ActionTaken: false, }, nil } if err := a.ha.TurnOnLight(ctx, entityID, params); err != nil { a.log.Error("turn on light failed", "entity_id", entityID, "err", err) return QueryResult{ Reply: "I couldn't reach Home Assistant right now.", Intent: intent.Name, ActionTaken: false, }, nil } return QueryResult{ Reply: fallbackReply(intent.Reply, fmt.Sprintf("Turned on `%s`.", displayLightName(entityID, lights))), Intent: intent.Name, ActionTaken: true, }, nil case domain.IntentTurnOffLight: entityID, ok := resolveLightEntity(intent.Entity, lights) if !ok { return QueryResult{Reply: "I couldn't find that light.", Intent: intent.Name}, nil } if err := a.ha.TurnOffLight(ctx, entityID); err != nil { a.log.Error("turn off light failed", "entity_id", entityID, "err", err) return QueryResult{ Reply: "I couldn't reach Home Assistant right now.", Intent: intent.Name, ActionTaken: false, }, nil } return QueryResult{ Reply: fallbackReply(intent.Reply, fmt.Sprintf("Turned off `%s`.", displayLightName(entityID, lights))), Intent: intent.Name, ActionTaken: true, }, nil case domain.IntentListLights: return QueryResult{ Reply: formatLightListReply(lights), Intent: intent.Name, ActionTaken: false, }, nil case domain.IntentNone: fallthrough default: return QueryResult{ Reply: fallbackReply(intent.Reply, "I didn't understand that."), Intent: intent.Name, ActionTaken: false, }, nil } } func promptLightLines(lights []driven.Light) []string { lines := make([]string, 0, len(lights)) for _, light := range lights { label := light.FriendlyName if label == "" { label = light.EntityID } lines = append(lines, fmt.Sprintf("- %s (%s) state=%s", label, light.EntityID, light.State)) } return lines } func resolveLightEntity(value string, lights []driven.Light) (string, bool) { needle := strings.TrimSpace(strings.ToLower(value)) if needle == "" { return "", false } idx := slices.IndexFunc(lights, func(light driven.Light) bool { return strings.ToLower(light.EntityID) == needle || strings.ToLower(light.FriendlyName) == needle }) if idx != -1 { return lights[idx].EntityID, true } idx = slices.IndexFunc(lights, func(light driven.Light) bool { return strings.Contains(strings.ToLower(light.FriendlyName), needle) }) if idx != -1 { return lights[idx].EntityID, true } return "", false } func displayLightName(entityID string, lights []driven.Light) string { idx := slices.IndexFunc(lights, func(light driven.Light) bool { return light.EntityID == entityID }) if idx == -1 || lights[idx].FriendlyName == "" { return entityID } return lights[idx].FriendlyName } func fallbackReply(reply, fallback string) string { if strings.TrimSpace(reply) == "" { return fallback } return reply } func formatLightListReply(lights []driven.Light) string { if len(lights) == 0 { return "No lights found." } lines := make([]string, 0, len(lights)+1) lines = append(lines, "Known lights:") for _, light := range lights { label := light.FriendlyName if label == "" { label = light.EntityID } lines = append(lines, fmt.Sprintf("- %s (%s) [%s]", label, light.EntityID, light.State)) } return strings.Join(lines, "\n") } // ParseLightParams converts string params into the protobuf-compatible values the HA adapter expects. func ParseLightParams(params map[string]string) (map[string]string, error) { if len(params) == 0 { return map[string]string{}, nil } normalized := make(map[string]string, len(params)) for key, value := range params { switch key { case "brightness", "brightness_pct", "color_temp", "color_temp_kelvin": if _, err := strconv.ParseUint(value, 10, 32); err != nil { return nil, fmt.Errorf("invalid %s value %q: %w", key, value, err) } } normalized[key] = value } return normalized, nil }