Add deterministic surface fast-path to discovery (no model for obvious services)

Discovery should be instant for the obvious case: a workload named after
its service ('home-assistant', 'frigate', 'mqtt'…) is identified from the
name alone, with no model call and no command scan needed. This is the
'surface index' — identity + how-to-reach — with depth left to the
Assistant's own knowledge and on-demand commands.

Expand knownServiceIdentities from one entry (esphome) to the common
homelab set, and run inferSurfaceIdentity BEFORE the model: on a
name match, build the identity result and skip the (slow reasoning-model)
analysis entirely. Conservative — name signals only, never broad
command-output guesses — so the model is skipped only on an obvious match;
ambiguous workloads still fall through to full analysis.

Verified live on real infra: HA LXC now identifies in ~0s as Home
Assistant (0.9), no model call, even with the agent disconnected —
previously it timed out at 45s on the reasoning model.

Tests: new inferSurfaceIdentity coverage; updated three tests whose
fixtures were named after known services (they now take the fast-path) —
abstention test uses a generic name, repair test expects 0 model calls,
cached test uses a complete identity. Full package green.
This commit is contained in:
rcourtman
2026-06-07 21:15:11 +01:00
parent db6694774f
commit 623d3e7749
4 changed files with 156 additions and 12 deletions
+12 -1
View File
@@ -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
+77 -7
View File
@@ -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
@@ -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)
}
}
+14 -4
View File
@@ -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(),