mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 19:23:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user