Fix Discovery response JSON extraction

Refs #1479
This commit is contained in:
rcourtman 2026-05-25 18:02:28 +01:00
parent ed67706824
commit c82817c099
6 changed files with 156 additions and 59 deletions

View file

@ -16,6 +16,7 @@ import (
"time"
"unicode"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/jsonresponse"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
@ -843,35 +844,16 @@ func (s *Service) parseAIResponse(response string) *DiscoveryResult {
return nil
}
// Handle markdown code blocks
if strings.HasPrefix(response, "```") {
lines := strings.Split(response, "\n")
var jsonLines []string
inBlock := false
for _, line := range lines {
if strings.HasPrefix(line, "```") {
inBlock = !inBlock
continue
}
if inBlock {
jsonLines = append(jsonLines, line)
}
}
response = strings.Join(jsonLines, "\n")
}
// Find JSON object in response
start := strings.Index(response, "{")
end := strings.LastIndex(response, "}")
if start >= 0 && end > start {
response = response[start : end+1]
payload, ok := jsonresponse.ExtractObject(response)
if !ok {
return nil
}
var result DiscoveryResult
if err := json.Unmarshal([]byte(response), &result); err != nil {
if err := json.Unmarshal([]byte(payload), &result); err != nil {
log.Debug().
Err(err).
Str("response", sanitizeText(response, maxAIParseLogFieldLength)).
Str("response", sanitizeText(payload, maxAIParseLogFieldLength)).
Msg("Failed to parse AI response as JSON")
return nil
}

View file

@ -187,6 +187,18 @@ func TestParseAIResponse(t *testing.T) {
Reasoning: "Redis image",
},
},
{
name: "inline language fence",
response: "```json {\"service_type\":\"debian\",\"service_name\":\"Debian 13 (Trixie) Server\",\"category\":\"virtualizer\",\"cli_command\":\"docker exec {container} cat /etc/os-release\",\"confidence\":0.95,\"reasoning\":\"os release\"} ```",
want: &DiscoveryResult{
ServiceType: "debian",
ServiceName: "Debian 13 (Trixie) Server",
Category: "virtualizer",
CLICommand: "docker exec {container} cat /etc/os-release",
Confidence: 0.95,
Reasoning: "os release",
},
},
{
name: "invalid JSON",
response: "not json at all",

View file

@ -0,0 +1,35 @@
// Package jsonresponse extracts JSON payloads from model responses.
package jsonresponse
import (
"encoding/json"
"strings"
)
// ExtractObject returns the first valid JSON object embedded in a model
// response. Models sometimes wrap JSON in markdown, inline code spans, or a
// leading language tag, so callers should not rely on the response being a
// clean JSON document.
func ExtractObject(response string) (string, bool) {
response = strings.TrimSpace(response)
if response == "" {
return "", false
}
for i, r := range response {
if r != '{' {
continue
}
decoder := json.NewDecoder(strings.NewReader(response[i:]))
var raw json.RawMessage
if err := decoder.Decode(&raw); err != nil {
continue
}
if len(raw) > 0 && raw[0] == '{' {
return string(raw), true
}
}
return "", false
}

View file

@ -0,0 +1,65 @@
package jsonresponse
import "testing"
func TestExtractObject(t *testing.T) {
tests := []struct {
name string
response string
want string
wantOK bool
}{
{
name: "plain object",
response: `{"service_type":"nginx"}`,
want: `{"service_type":"nginx"}`,
wantOK: true,
},
{
name: "markdown block",
response: "```json\n{\"service_type\":\"redis\"}\n```",
want: `{"service_type":"redis"}`,
wantOK: true,
},
{
name: "inline markdown fence with language tag",
response: "```json {\"service_type\":\"debian\",\"facts\":[{\"key\":\"OS Codename\",\"value\":\"trixie\"}]} ```",
want: `{"service_type":"debian","facts":[{"key":"OS Codename","value":"trixie"}]}`,
wantOK: true,
},
{
name: "single backtick and language tag",
response: "`json {\"service_type\":\"debian\"}`",
want: `{"service_type":"debian"}`,
wantOK: true,
},
{
name: "surrounding prose with braces before object",
response: `Analysis {not json}: {"service_type":"postgres","reasoning":"brace { in string } stays valid"} done.`,
want: `{"service_type":"postgres","reasoning":"brace { in string } stays valid"}`,
wantOK: true,
},
{
name: "invalid",
response: "not json at all",
wantOK: false,
},
{
name: "incomplete object",
response: `json {"service_type":"nginx"`,
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ExtractObject(tt.response)
if ok != tt.wantOK {
t.Fatalf("ExtractObject() ok = %v, want %v", ok, tt.wantOK)
}
if got != tt.want {
t.Fatalf("ExtractObject() = %q, want %q", got, tt.want)
}
})
}
}

View file

@ -15,6 +15,7 @@ import (
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/jsonresponse"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
@ -2502,35 +2503,14 @@ Respond with ONLY valid JSON.`, strings.Join(sections, "\n\n"))
// parseAIResponse parses the AI's JSON response.
func (s *Service) parseAIResponse(response string) *AIAnalysisResponse {
log.Debug().Str("raw_response", response).Msg("discovery raw response")
response = strings.TrimSpace(response)
// Handle markdown code blocks
if strings.HasPrefix(response, "```") {
lines := strings.Split(response, "\n")
var jsonLines []string
inBlock := false
for _, line := range lines {
if strings.HasPrefix(line, "```") {
inBlock = !inBlock
continue
}
if inBlock {
jsonLines = append(jsonLines, line)
}
}
response = strings.Join(jsonLines, "\n")
}
// Find JSON object
start := strings.Index(response, "{")
end := strings.LastIndex(response, "}")
if start >= 0 && end > start {
response = response[start : end+1]
payload, ok := jsonresponse.ExtractObject(response)
if !ok {
return nil
}
var result AIAnalysisResponse
if err := json.Unmarshal([]byte(response), &result); err != nil {
log.Debug().Err(err).Str("response", response).Msg("failed to parse AI response")
if err := json.Unmarshal([]byte(payload), &result); err != nil {
log.Debug().Err(err).Str("response", payload).Msg("failed to parse AI response")
return nil
}

View file

@ -437,17 +437,40 @@ func TestService_GetSnapshot_WithoutReadStateReturnsCanonicalEmptySnapshot(t *te
func TestService_parseAIResponse_Markdown(t *testing.T) {
service := &Service{}
response := "```json\n{\n \"service_type\": \"nginx\",\n \"service_name\": \"Nginx\",\n \"service_version\": \"1.2\",\n \"category\": \"web_server\",\n \"cli_access\": \"docker exec {container} bash\",\n \"facts\": [{\"category\": \"version\", \"key\": \"nginx\", \"value\": \"1.2\", \"source\": \"cmd\", \"confidence\": 0.9}],\n \"config_paths\": [\"/etc/nginx/nginx.conf\"],\n \"data_paths\": [\"/var/www\"],\n \"ports\": [{\"port\": 80, \"protocol\": \"tcp\", \"process\": \"nginx\", \"address\": \"0.0.0.0\"}],\n \"confidence\": 0.9,\n \"reasoning\": \"image name\"\n}\n```"
parsed := service.parseAIResponse(response)
if parsed == nil {
t.Fatalf("expected parsed response")
tests := []struct {
name string
response string
wantService string
wantName string
}{
{
name: "markdown block",
response: "```json\n{\n \"service_type\": \"nginx\",\n \"service_name\": \"Nginx\",\n \"service_version\": \"1.2\",\n \"category\": \"web_server\",\n \"cli_access\": \"docker exec {container} bash\",\n \"facts\": [{\"category\": \"version\", \"key\": \"nginx\", \"value\": \"1.2\", \"source\": \"cmd\", \"confidence\": 0.9}],\n \"config_paths\": [\"/etc/nginx/nginx.conf\"],\n \"data_paths\": [\"/var/www\"],\n \"ports\": [{\"port\": 80, \"protocol\": \"tcp\", \"process\": \"nginx\", \"address\": \"0.0.0.0\"}],\n \"confidence\": 0.9,\n \"reasoning\": \"image name\"\n}\n```",
wantService: "nginx",
wantName: "Nginx",
},
{
name: "inline language fence",
response: "```json {\"service_type\":\"debian\",\"service_name\":\"Debian 13 (Trixie) Server\",\"service_version\":\"13.5\",\"category\":\"virtualizer\",\"cli_access\":\"ssh user@pve-qdev\",\"facts\":[{\"category\":\"version\",\"key\":\"OS Codename\",\"value\":\"trixie\",\"source\":\"os_release\",\"confidence\":0.95}],\"config_paths\":[],\"data_paths\":[],\"ports\":[],\"confidence\":0.95,\"reasoning\":\"os release\"} ```",
wantService: "debian",
wantName: "Debian 13 (Trixie) Server",
},
}
if parsed.ServiceType != "nginx" || parsed.ServiceName != "Nginx" {
t.Fatalf("unexpected parsed result: %#v", parsed)
}
if len(parsed.Facts) != 1 || parsed.Facts[0].DiscoveredAt.IsZero() {
t.Fatalf("expected fact timestamp set: %#v", parsed.Facts)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsed := service.parseAIResponse(tt.response)
if parsed == nil {
t.Fatalf("expected parsed response")
}
if parsed.ServiceType != tt.wantService || parsed.ServiceName != tt.wantName {
t.Fatalf("unexpected parsed result: %#v", parsed)
}
if len(parsed.Facts) != 1 || parsed.Facts[0].DiscoveredAt.IsZero() {
t.Fatalf("expected fact timestamp set: %#v", parsed.Facts)
}
})
}
if service.parseAIResponse("not json") != nil {