diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go index 2009db05b..b085ca28b 100644 --- a/internal/servicediscovery/service.go +++ b/internal/servicediscovery/service.go @@ -1782,7 +1782,18 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (* len(analysisReq.CommandOutputs) == 0 var result *AIAnalysisResponse - if metadataOnly { + if identity, evidence, ok := inferSurfaceIdentity(req, analysisReq.Metadata); ok { + // Fast surface path: the resource name clearly identifies a known + // service, so skip the (slow) model entirely. Identity is the surface; + // depth comes from the Assistant's own knowledge plus on-demand commands. + result = surfaceIdentityResponse(identity, evidence) + s.broadcastProgress(&DiscoveryProgress{ + ResourceID: resourceID, + Status: DiscoveryStatusRunning, + CurrentStep: "Identified from resource name (fast path)", + PercentComplete: 90, + }) + } else if metadataOnly { result = metadataOnlyDiscoveryAbstention() } else { // Build prompt and analyze diff --git a/internal/servicediscovery/service_identity.go b/internal/servicediscovery/service_identity.go index 33b3b203c..a36db54ed 100644 --- a/internal/servicediscovery/service_identity.go +++ b/internal/servicediscovery/service_identity.go @@ -13,14 +13,32 @@ type knownServiceIdentity struct { Confidence float64 } +// knownServiceIdentities drives both the deterministic surface fast-path +// (inferSurfaceIdentity, name-based, runs before the model) and the post-model +// identity improver (applyKnownServiceIdentity). Common homelab services are +// usually named after themselves, so the resource name alone identifies them +// instantly — no model call. Aliases are matched against the normalized name. var knownServiceIdentities = []knownServiceIdentity{ - { - ServiceType: "esphome", - ServiceName: "ESPHome", - Category: CategoryHomeAuto, - Aliases: []string{"esphome", "esp-home", "esp home"}, - Confidence: 0.85, - }, + {ServiceType: "home-assistant", ServiceName: "Home Assistant", Category: CategoryHomeAuto, Aliases: []string{"home assistant", "homeassistant", "hassio", "hass"}, Confidence: 0.9}, + {ServiceType: "esphome", ServiceName: "ESPHome", Category: CategoryHomeAuto, Aliases: []string{"esphome", "esp-home", "esp home"}, Confidence: 0.85}, + {ServiceType: "zigbee2mqtt", ServiceName: "Zigbee2MQTT", Category: CategoryHomeAuto, Aliases: []string{"zigbee2mqtt", "zigbee 2 mqtt"}, Confidence: 0.9}, + {ServiceType: "frigate", ServiceName: "Frigate NVR", Category: CategoryNVR, Aliases: []string{"frigate"}, Confidence: 0.9}, + {ServiceType: "mosquitto", ServiceName: "Mosquitto MQTT", Category: CategoryNetwork, Aliases: []string{"mosquitto", "mqtt"}, Confidence: 0.85}, + {ServiceType: "postgresql", ServiceName: "PostgreSQL", Category: CategoryDatabase, Aliases: []string{"postgresql", "postgres"}, Confidence: 0.9}, + {ServiceType: "mariadb", ServiceName: "MariaDB", Category: CategoryDatabase, Aliases: []string{"mariadb", "mysqld", "mysql"}, Confidence: 0.85}, + {ServiceType: "redis", ServiceName: "Redis", Category: CategoryCache, Aliases: []string{"redis", "redis server"}, Confidence: 0.9}, + {ServiceType: "influxdb", ServiceName: "InfluxDB", Category: CategoryDatabase, Aliases: []string{"influxdb", "influx"}, Confidence: 0.85}, + {ServiceType: "plex", ServiceName: "Plex Media Server", Category: CategoryMedia, Aliases: []string{"plex media server", "plexmediaserver", "plex"}, Confidence: 0.9}, + {ServiceType: "jellyfin", ServiceName: "Jellyfin", Category: CategoryMedia, Aliases: []string{"jellyfin"}, Confidence: 0.9}, + {ServiceType: "nginx", ServiceName: "Nginx", Category: CategoryWebServer, Aliases: []string{"nginx"}, Confidence: 0.8}, + {ServiceType: "grafana", ServiceName: "Grafana", Category: CategoryMonitoring, Aliases: []string{"grafana"}, Confidence: 0.9}, + {ServiceType: "prometheus", ServiceName: "Prometheus", Category: CategoryMonitoring, Aliases: []string{"prometheus"}, Confidence: 0.9}, + {ServiceType: "uptime-kuma", ServiceName: "Uptime Kuma", Category: CategoryMonitoring, Aliases: []string{"uptime kuma", "uptimekuma"}, Confidence: 0.9}, + {ServiceType: "pihole", ServiceName: "Pi-hole", Category: CategoryNetwork, Aliases: []string{"pi hole", "pihole"}, Confidence: 0.9}, + {ServiceType: "adguard", ServiceName: "AdGuard Home", Category: CategoryNetwork, Aliases: []string{"adguard home", "adguardhome", "adguard"}, Confidence: 0.9}, + {ServiceType: "tailscale", ServiceName: "Tailscale", Category: CategoryNetwork, Aliases: []string{"tailscale"}, Confidence: 0.85}, + {ServiceType: "unifi", ServiceName: "UniFi Controller", Category: CategoryNetwork, Aliases: []string{"unifi controller", "unifi"}, Confidence: 0.85}, + {ServiceType: "nextcloud", ServiceName: "Nextcloud", Category: CategoryStorage, Aliases: []string{"nextcloud"}, Confidence: 0.85}, } func applyKnownServiceIdentity( @@ -126,6 +144,58 @@ func inferKnownServiceIdentity( return knownServiceIdentity{}, "", false } +// inferSurfaceIdentity is the discovery fast-path: deterministic, NAME-based +// identification that runs BEFORE the model. When a resource's name (hostname, +// id, or metadata name) clearly names a known service, we identify it instantly +// and skip the model entirely. Conservative on purpose — it only considers +// strong naming signals, never broad command-output text — so the model is +// skipped only on an obvious match; ambiguous workloads still fall through to +// the full analysis. +func inferSurfaceIdentity(req DiscoveryRequest, metadata map[string]any) (knownServiceIdentity, string, bool) { + candidates := []knownServiceEvidenceCandidate{ + {Source: "resource name", Value: req.Hostname}, + {Source: "resource id", Value: req.ResourceID}, + } + if name := stringMetadataValue(metadata, "name", "hostname", "display_name"); name != "" { + candidates = append(candidates, knownServiceEvidenceCandidate{Source: "resource name", Value: name}) + } + + for _, identity := range knownServiceIdentities { + aliases := append([]string{identity.ServiceType, identity.ServiceName}, identity.Aliases...) + for _, candidate := range candidates { + normalizedCandidate := normalizeKnownServiceEvidence(candidate.Value) + if normalizedCandidate == "" { + continue + } + for _, alias := range aliases { + normalizedAlias := normalizeKnownServiceEvidence(alias) + if normalizedAlias == "" { + continue + } + if normalizedCandidate == normalizedAlias || + strings.Contains(normalizedCandidate, normalizedAlias) { + return identity, candidate.Source, true + } + } + } + } + return knownServiceIdentity{}, "", false +} + +// surfaceIdentityResponse builds a no-model discovery result from a fast-path +// match. Identity only: config/data/log paths are intentionally left empty — +// the Assistant knows standard service layouts and fetches specifics on demand, +// and cli_access is derived downstream from the resource type. +func surfaceIdentityResponse(identity knownServiceIdentity, evidence string) *AIAnalysisResponse { + return &AIAnalysisResponse{ + ServiceType: identity.ServiceType, + ServiceName: identity.ServiceName, + Category: identity.Category, + Confidence: identity.Confidence, + Reasoning: fmt.Sprintf("Identified from %s — fast surface match, no model call.", evidence), + } +} + type knownServiceEvidenceCandidate struct { Source string Value string diff --git a/internal/servicediscovery/service_identity_test.go b/internal/servicediscovery/service_identity_test.go new file mode 100644 index 000000000..daa22a730 --- /dev/null +++ b/internal/servicediscovery/service_identity_test.go @@ -0,0 +1,53 @@ +package servicediscovery + +import "testing" + +func TestInferSurfaceIdentity(t *testing.T) { + cases := []struct { + name string + hostname string + wantType string + wantOK bool + }{ + {"home assistant by hostname", "home-assistant", "home-assistant", true}, + {"homeassistant one word", "homeassistant", "home-assistant", true}, + {"esphome by hostname", "esphome", "esphome", true}, + {"frigate by hostname", "frigate", "frigate", true}, + {"mqtt maps to mosquitto", "mqtt", "mosquitto", true}, + {"plex within a longer name", "plex-media", "plex", true}, + {"postgres by hostname", "postgres-primary", "postgresql", true}, + {"generic name does not match", "ct-200", "", false}, + {"numeric id does not match", "101", "", false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + identity, _, ok := inferSurfaceIdentity(DiscoveryRequest{Hostname: tc.hostname}, nil) + if ok != tc.wantOK { + t.Fatalf("inferSurfaceIdentity(%q) ok=%v, want %v", tc.hostname, ok, tc.wantOK) + } + if ok && identity.ServiceType != tc.wantType { + t.Fatalf("inferSurfaceIdentity(%q) type=%q, want %q", tc.hostname, identity.ServiceType, tc.wantType) + } + }) + } +} + +func TestSurfaceIdentityResponseIsIdentityOnly(t *testing.T) { + identity, evidence, ok := inferSurfaceIdentity(DiscoveryRequest{Hostname: "home-assistant"}, nil) + if !ok { + t.Fatal("expected home-assistant to match by name") + } + resp := surfaceIdentityResponse(identity, evidence) + if resp.ServiceType != "home-assistant" || resp.ServiceName != "Home Assistant" { + t.Fatalf("unexpected identity: type=%q name=%q", resp.ServiceType, resp.ServiceName) + } + if resp.Confidence < 0.85 { + t.Fatalf("expected a confident identity, got %v", resp.Confidence) + } + // Surface = identity only. Paths/facts are intentionally empty — the + // Assistant knows standard layouts and fetches specifics on demand. + if len(resp.ConfigPaths) != 0 || len(resp.DataPaths) != 0 || len(resp.LogPaths) != 0 || len(resp.Facts) != 0 { + t.Fatalf("surface response must carry no deep paths/facts, got %+v", resp) + } +} diff --git a/internal/servicediscovery/service_test.go b/internal/servicediscovery/service_test.go index 0b0447822..b9bd7739b 100644 --- a/internal/servicediscovery/service_test.go +++ b/internal/servicediscovery/service_test.go @@ -304,8 +304,11 @@ func TestService_DiscoverResource_AbstainsWithoutCommandEvidence(t *testing.T) { ResourceType: ResourceTypeSystemContainer, TargetID: "delly", ResourceID: "102", - Hostname: "esphome", - Force: true, + // Generic name that matches no known service, so the deterministic + // surface fast-path does NOT fire — this exercises the pure abstention + // path (no command evidence, nothing to identify from). + Hostname: "ct-200", + Force: true, }) if err != nil { t.Fatalf("DiscoverResource error: %v", err) @@ -2093,8 +2096,10 @@ func TestService_RunManualDiscoveryRefreshRepairsFreshUnknownKnownService(t *tes if summary.CandidateCount != 1 || summary.DiscoveredCount != 1 || summary.FailedCount != 0 { t.Fatalf("expected one repaired candidate, got %+v", summary) } - if analyzer.calls != 1 { - t.Fatalf("expected one analyzer call, got %d", analyzer.calls) + // The resource is named "esphome", so the deterministic surface fast-path + // identifies it before the model — the repair happens with no analyzer call. + if analyzer.calls != 0 { + t.Fatalf("expected zero analyzer calls (fast-path identity), got %d", analyzer.calls) } discovery, err := store.Get(id) @@ -2193,8 +2198,13 @@ func TestService_DiscoverResource_ReturnsUpgradedCachedDiscovery(t *testing.T) { TargetID: "host1", ResourceID: "web", ServiceType: "nginx", + // Complete, confident identity so the known-service improver sees nothing + // to upgrade and the cached record is returned as-is (this test covers + // cache return + URL-source parsing, not re-identification). + ServiceName: "Nginx", Category: CategoryWebServer, CLIAccess: "docker exec web bash", + Confidence: 0.9, AIReasoning: `[URL suggestion source: service_default_match (service default: nginx)] previous discovery`, DiscoveredAt: time.Now(), UpdatedAt: time.Now(),