package api import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rs/zerolog/log" ) // ProfileSuggestionHandler handles AI-assisted profile suggestions type ProfileSuggestionHandler struct { mtPersistence *config.MultiTenantPersistence legacyPersistence *config.ConfigPersistence aiHandler *AIHandler } // NewProfileSuggestionHandler creates a new suggestion handler func NewProfileSuggestionHandler(mtp *config.MultiTenantPersistence, legacyPersistence *config.ConfigPersistence, aiHandler *AIHandler) *ProfileSuggestionHandler { return &ProfileSuggestionHandler{ mtPersistence: mtp, legacyPersistence: legacyPersistence, aiHandler: aiHandler, } } // SuggestionRequest is the request body for profile suggestions type SuggestionRequest struct { Prompt string `json:"prompt"` } // ProfileSuggestion is the AI-generated profile suggestion type ProfileSuggestion struct { Name string `json:"name"` Description string `json:"description"` Config map[string]interface{} `json:"config"` Rationale []string `json:"rationale"` } // HandleSuggestProfile handles POST /api/admin/profiles/suggestions func (h *ProfileSuggestionHandler) HandleSuggestProfile(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Check if AI is running if h.aiHandler == nil || !h.aiHandler.IsRunning(r.Context()) { http.Error(w, "Pulse Assistant service is not available", http.StatusServiceUnavailable) return } // Parse request var req SuggestionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Validate prompt is not empty req.Prompt = strings.TrimSpace(req.Prompt) if req.Prompt == "" { http.Error(w, "Prompt is required", http.StatusBadRequest) return } // Build context for the AI contextParts := []string{} // Add existing profiles for reference (if persistence is available) var persistence *config.ConfigPersistence if h.mtPersistence != nil { orgID := GetOrgID(r.Context()) persistence, _ = h.mtPersistence.GetPersistence(orgID) } if persistence == nil { persistence = h.legacyPersistence } if persistence != nil { profiles, err := persistence.LoadAgentProfiles() if err == nil && len(profiles) > 0 { profileNames := make([]string, len(profiles)) for i, p := range profiles { profileNames[i] = p.Name } contextParts = append(contextParts, fmt.Sprintf("Existing profiles: %s", strings.Join(profileNames, ", "))) } } // Build config schema documentation from the actual definitions configDocs := buildConfigSchemaDoc() // Build the prompt for the AI (schema docs only in system prompt, not in context) systemPrompt := fmt.Sprintf(`You are an infrastructure configuration assistant for Pulse, a monitoring platform. Your task is to suggest an agent configuration profile based on the user's request. IMPORTANT: You must respond ONLY with a valid JSON object in this exact format: { "name": "Profile Name", "description": "Brief description of what this profile is for", "config": { "key": "value" }, "rationale": ["Reason 1", "Reason 2"] } Available configuration keys and their types: %s Only include settings that are relevant to the user's request. Do not include settings with default values. `, configDocs) userPrompt := req.Prompt if len(contextParts) > 0 { userPrompt = fmt.Sprintf("Context:\n%s\n\nRequest: %s", strings.Join(contextParts, "\n"), req.Prompt) } fullPrompt := fmt.Sprintf("%s\n\nUser request: %s\n\nRespond with ONLY the JSON object, no markdown, no explanation.", systemPrompt, userPrompt) // Call the AI service ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) defer cancel() response, err := h.aiHandler.GetService(ctx).Execute(ctx, chat.ExecuteRequest{ Prompt: fullPrompt, }) if err != nil { log.Error().Err(err).Msg("Failed to get AI suggestion") http.Error(w, "Failed to generate suggestion", http.StatusInternalServerError) return } fullResponse, _ := response["content"].(string) if fullResponse == "" { log.Error().Msg("Pulse Assistant returned empty response") http.Error(w, "Pulse Assistant returned empty response", http.StatusInternalServerError) return } suggestion, err := parseAISuggestion(fullResponse) if err != nil { log.Error().Err(err).Str("response", fullResponse).Msg("Failed to parse AI suggestion") // Return a friendly error with partial info if available http.Error(w, fmt.Sprintf("Failed to parse Pulse Assistant response: %v", err), http.StatusInternalServerError) return } // Validate the suggested config validator := models.NewProfileValidator() if suggestion.Config != nil { configMap := models.AgentConfigMap{} for k, v := range suggestion.Config { configMap[k] = v } result := validator.Validate(configMap) if !result.Valid { // Include warnings in response but don't fail log.Warn().Interface("errors", result.Errors).Msg("Suggestion has validation warnings") } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(suggestion) } // parseAISuggestion extracts the ProfileSuggestion from the AI response func parseAISuggestion(text string) (*ProfileSuggestion, error) { // Try to find JSON in the response text = strings.TrimSpace(text) // Remove ALL markdown code block markers text = strings.ReplaceAll(text, "```json", "") text = strings.ReplaceAll(text, "```", "") text = strings.TrimSpace(text) // Find JSON object boundaries - use brace counting to find the complete JSON start := strings.Index(text, "{") if start == -1 { return nil, fmt.Errorf("no JSON object found in response") } // Count braces to find the matching closing brace braceCount := 0 end := -1 inString := false escape := false for i := start; i < len(text); i++ { c := text[i] if escape { escape = false continue } if c == '\\' { escape = true continue } if c == '"' { inString = !inString continue } if inString { continue } if c == '{' { braceCount++ } else if c == '}' { braceCount-- if braceCount == 0 { end = i break } } } if end == -1 { return nil, fmt.Errorf("no complete JSON object found in response") } jsonStr := text[start : end+1] var suggestion ProfileSuggestion if err := json.Unmarshal([]byte(jsonStr), &suggestion); err != nil { return nil, fmt.Errorf("invalid JSON: %w", err) } // Validate required fields if suggestion.Name == "" { suggestion.Name = "Suggested Profile" } if suggestion.Description == "" { suggestion.Description = "Pulse Assistant-generated configuration profile" } if suggestion.Config == nil { suggestion.Config = make(map[string]interface{}) } if suggestion.Rationale == nil { suggestion.Rationale = []string{} } return &suggestion, nil } // buildConfigSchemaDoc generates documentation for all config keys from the schema func buildConfigSchemaDoc() string { defs := models.GetConfigKeyDefinitions() var lines []string for _, def := range defs { var typeStr string switch def.Type { case models.ConfigTypeBool: typeStr = "boolean" case models.ConfigTypeString: typeStr = "string" case models.ConfigTypeInt: typeStr = "integer" if def.Min != nil || def.Max != nil { constraints := []string{} if def.Min != nil { constraints = append(constraints, fmt.Sprintf("min: %.0f", *def.Min)) } if def.Max != nil { constraints = append(constraints, fmt.Sprintf("max: %.0f", *def.Max)) } typeStr += " (" + strings.Join(constraints, ", ") + ")" } case models.ConfigTypeFloat: typeStr = "number" if def.Min != nil || def.Max != nil { constraints := []string{} if def.Min != nil { constraints = append(constraints, fmt.Sprintf("min: %.1f", *def.Min)) } if def.Max != nil { constraints = append(constraints, fmt.Sprintf("max: %.1f", *def.Max)) } typeStr += " (" + strings.Join(constraints, ", ") + ")" } case models.ConfigTypeDuration: typeStr = "duration string (e.g., \"30s\", \"1m\", \"5m\")" case models.ConfigTypeEnum: typeStr = fmt.Sprintf("enum: %s", strings.Join(def.Enum, ", ")) default: typeStr = string(def.Type) } line := fmt.Sprintf("- %s (%s): %s", def.Key, typeStr, def.Description) if def.Default != nil && def.Default != "" { line += fmt.Sprintf(" [default: %v]", def.Default) } lines = append(lines, line) } return strings.Join(lines, "\n") }