mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
ed67706824
commit
c82817c099
6 changed files with 156 additions and 59 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
35
internal/ai/jsonresponse/jsonresponse.go
Normal file
35
internal/ai/jsonresponse/jsonresponse.go
Normal 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
|
||||
}
|
||||
65
internal/ai/jsonresponse/jsonresponse_test.go
Normal file
65
internal/ai/jsonresponse/jsonresponse_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue