From 31f4e0c46de5c958fdbd9a86ac914870c7f6c074 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 22 Dec 2025 23:46:40 +0100 Subject: [PATCH] chore(gallery agent): various fixups (#7697) * chore(ci/agent): fix formatting issues Signed-off-by: Ettore Di Giacinto * chore: get icon from readme/hf and prepend to the gallery file Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto --- .github/gallery-agent/agent.go | 206 +++++++++++++++++++++++++------ .github/gallery-agent/gallery.go | 164 +++++++++++++----------- .github/gallery-agent/main.go | 22 ++++ .github/gallery-agent/testing.go | 34 +++++ 4 files changed, 310 insertions(+), 116 deletions(-) diff --git a/.github/gallery-agent/agent.go b/.github/gallery-agent/agent.go index a1a481c07..5efd58dd2 100644 --- a/.github/gallery-agent/agent.go +++ b/.github/gallery-agent/agent.go @@ -2,7 +2,10 @@ package main import ( "context" + "encoding/json" "fmt" + "io" + "net/http" "os" "regexp" "slices" @@ -229,71 +232,192 @@ Return your analysis and selection reasoning.`) return filteredModels, nil } -// ModelFamily represents a YAML anchor/family -type ModelFamily struct { - Anchor string `json:"anchor"` - Name string `json:"name"` +// ModelMetadata represents extracted metadata from a model +type ModelMetadata struct { + Tags []string `json:"tags"` + License string `json:"license"` } -// selectModelFamily selects the appropriate model family/anchor for a given model -func selectModelFamily(ctx context.Context, model ProcessedModel, availableFamilies []ModelFamily) (string, error) { +// extractModelMetadata extracts tags and license from model README and documentation +func extractModelMetadata(ctx context.Context, model ProcessedModel) ([]string, string, error) { // Create a conversation fragment fragment := cogito.NewEmptyFragment(). AddMessage("user", - `Your task is to select the most appropriate model family/anchor for a given AI model. You will be provided with: -1. Information about the model (name, description, etc.) -2. A list of available model families/anchors + `Your task is to extract metadata from an AI model's README and documentation. You will be provided with: +1. Model information (ID, author, description) +2. README content -You need to select the family that best matches the model's architecture, capabilities, or characteristics. Consider: -- Model architecture (e.g., Llama, Qwen, Mistral, etc.) -- Model capabilities (e.g., vision, coding, chat, etc.) -- Model size/type (e.g., small, medium, large) -- Model purpose (e.g., general purpose, specialized, etc.) +You need to extract: +1. **Tags**: An array of relevant tags that describe the model. Use common tags from the gallery such as: + - llm, gguf, gpu, cpu, multimodal, image-to-text, text-to-text, text-to-speech, tts + - thinking, reasoning, chat, instruction-tuned, code, vision + - Model family names (e.g., llama, qwen, mistral, gemma) if applicable + - Any other relevant descriptive tags + Select 3-8 most relevant tags. -Return the anchor name that best fits the model.`) +2. **License**: The license identifier (e.g., "apache-2.0", "mit", "llama2", "gpl-3.0", "bsd", "cc-by-4.0"). + If no license is found, return an empty string. + +Return the extracted metadata in a structured format.`) // Add model information modelInfo := "Model Information:\n" modelInfo += fmt.Sprintf(" ID: %s\n", model.ModelID) modelInfo += fmt.Sprintf(" Author: %s\n", model.Author) modelInfo += fmt.Sprintf(" Downloads: %d\n", model.Downloads) - modelInfo += fmt.Sprintf(" Description: %s\n", model.ReadmeContentPreview) - - fragment = fragment.AddMessage("user", modelInfo) - - // Add available families - familiesInfo := "Available Model Families:\n" - for _, family := range availableFamilies { - familiesInfo += fmt.Sprintf(" - %s (%s)\n", family.Anchor, family.Name) + if model.ReadmeContent != "" { + modelInfo += fmt.Sprintf(" README Content:\n%s\n", model.ReadmeContent) + } else if model.ReadmeContentPreview != "" { + modelInfo += fmt.Sprintf(" README Preview: %s\n", model.ReadmeContentPreview) } - fragment = fragment.AddMessage("user", familiesInfo) - fragment = fragment.AddMessage("user", "Select the most appropriate family anchor for this model. Return just the anchor name.") + fragment = fragment.AddMessage("user", modelInfo) + fragment = fragment.AddMessage("user", "Extract the tags and license from the model information. Return the metadata as a JSON object with 'tags' (array of strings) and 'license' (string).") // Get a response newFragment, err := llm.Ask(ctx, fragment) if err != nil { - return "", err + return nil, "", err } - // Extract the selected family - selectedFamily := strings.TrimSpace(newFragment.LastMessage().Content) + // Extract structured metadata + metadata := ModelMetadata{} - // Validate that the selected family exists in our list - for _, family := range availableFamilies { - if family.Anchor == selectedFamily { - return selectedFamily, nil - } + s := structures.Structure{ + Schema: jsonschema.Definition{ + Type: jsonschema.Object, + AdditionalProperties: false, + Properties: map[string]jsonschema.Definition{ + "tags": { + Type: jsonschema.Array, + Items: &jsonschema.Definition{Type: jsonschema.String}, + Description: "Array of relevant tags describing the model", + }, + "license": { + Type: jsonschema.String, + Description: "License identifier (e.g., apache-2.0, mit, llama2). Empty string if not found.", + }, + }, + Required: []string{"tags", "license"}, + }, + Object: &metadata, } - // If no exact match, try to find a close match - for _, family := range availableFamilies { - if strings.Contains(strings.ToLower(family.Anchor), strings.ToLower(selectedFamily)) || - strings.Contains(strings.ToLower(selectedFamily), strings.ToLower(family.Anchor)) { - return family.Anchor, nil - } + err = newFragment.ExtractStructure(ctx, llm, s) + if err != nil { + return nil, "", err } - // Default fallback - return "llama3", nil + return metadata.Tags, metadata.License, nil +} + +// extractIconFromReadme scans the README content for image URLs and returns the first suitable icon URL found +func extractIconFromReadme(readmeContent string) string { + if readmeContent == "" { + return "" + } + + // Regular expressions to match image URLs in various formats (case-insensitive) + // Match markdown image syntax: ![alt](url) - case insensitive extensions + markdownImageRegex := regexp.MustCompile(`(?i)!\[[^\]]*\]\(([^)]+\.(png|jpg|jpeg|svg|webp|gif))\)`) + // Match HTML img tags: + htmlImageRegex := regexp.MustCompile(`(?i)]+src=["']([^"']+\.(png|jpg|jpeg|svg|webp|gif))["']`) + // Match plain URLs ending with image extensions + plainImageRegex := regexp.MustCompile(`(?i)https?://[^\s<>"']+\.(png|jpg|jpeg|svg|webp|gif)`) + + // Try markdown format first + matches := markdownImageRegex.FindStringSubmatch(readmeContent) + if len(matches) > 1 && matches[1] != "" { + url := strings.TrimSpace(matches[1]) + // Prefer HuggingFace CDN URLs or absolute URLs + if strings.HasPrefix(strings.ToLower(url), "http") { + return url + } + } + + // Try HTML img tags + matches = htmlImageRegex.FindStringSubmatch(readmeContent) + if len(matches) > 1 && matches[1] != "" { + url := strings.TrimSpace(matches[1]) + if strings.HasPrefix(strings.ToLower(url), "http") { + return url + } + } + + // Try plain URLs + matches = plainImageRegex.FindStringSubmatch(readmeContent) + if len(matches) > 0 { + url := strings.TrimSpace(matches[0]) + if strings.HasPrefix(strings.ToLower(url), "http") { + return url + } + } + + return "" +} + +// getHuggingFaceAvatarURL attempts to get the HuggingFace avatar URL for a user +func getHuggingFaceAvatarURL(author string) string { + if author == "" { + return "" + } + + // Try to fetch user info from HuggingFace API + // HuggingFace API endpoint: https://huggingface.co/api/users/{username} + baseURL := "https://huggingface.co" + userURL := fmt.Sprintf("%s/api/users/%s", baseURL, author) + + req, err := http.NewRequest("GET", userURL, nil) + if err != nil { + return "" + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "" + } + + // Parse the response to get avatar URL + var userInfo map[string]interface{} + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + if err := json.Unmarshal(body, &userInfo); err != nil { + return "" + } + + // Try to extract avatar URL from response + if avatar, ok := userInfo["avatarUrl"].(string); ok && avatar != "" { + return avatar + } + if avatar, ok := userInfo["avatar"].(string); ok && avatar != "" { + return avatar + } + + return "" +} + +// extractModelIcon extracts icon URL from README or falls back to HuggingFace avatar +func extractModelIcon(model ProcessedModel) string { + // First, try to extract icon from README + if icon := extractIconFromReadme(model.ReadmeContent); icon != "" { + return icon + } + + // Fallback: Try to get HuggingFace user avatar + if model.Author != "" { + if avatar := getHuggingFaceAvatarURL(model.Author); avatar != "" { + return avatar + } + } + + return "" } diff --git a/.github/gallery-agent/gallery.go b/.github/gallery-agent/gallery.go index b2e3d1d0b..431c06c85 100644 --- a/.github/gallery-agent/gallery.go +++ b/.github/gallery-agent/gallery.go @@ -12,15 +12,36 @@ import ( ) func formatTextContent(text string) string { + return formatTextContentWithIndent(text, 4, 6) +} + +// formatTextContentWithIndent formats text content with specified base and list item indentation +func formatTextContentWithIndent(text string, baseIndent int, listItemIndent int) string { var formattedLines []string lines := strings.Split(text, "\n") for _, line := range lines { - if strings.TrimSpace(line) == "" { + trimmed := strings.TrimRight(line, " \t\r") + if trimmed == "" { // Keep empty lines as empty (no indentation) formattedLines = append(formattedLines, "") } else { - // Add indentation to non-empty lines - formattedLines = append(formattedLines, " "+line) + // Preserve relative indentation from yaml.Marshal output + // Count existing leading spaces to preserve relative structure + leadingSpaces := len(trimmed) - len(strings.TrimLeft(trimmed, " \t")) + trimmedStripped := strings.TrimLeft(trimmed, " \t") + + var totalIndent int + if strings.HasPrefix(trimmedStripped, "-") { + // List items: use listItemIndent (ignore existing leading spaces) + totalIndent = listItemIndent + } else { + // Regular lines: use baseIndent + preserve relative indentation + // This handles both top-level keys (leadingSpaces=0) and nested properties (leadingSpaces>0) + totalIndent = baseIndent + leadingSpaces + } + + indentStr := strings.Repeat(" ", totalIndent) + formattedLines = append(formattedLines, indentStr+trimmedStripped) } } formattedText := strings.Join(formattedLines, "\n") @@ -30,7 +51,7 @@ func formatTextContent(text string) string { } // generateYAMLEntry generates a YAML entry for a model using the specified anchor -func generateYAMLEntry(model ProcessedModel, familyAnchor string, quantization string) string { +func generateYAMLEntry(model ProcessedModel, quantization string) string { modelConfig, err := importers.DiscoverModelConfig("https://huggingface.co/"+model.ModelID, json.RawMessage(`{ "quantization": "`+quantization+`"}`)) if err != nil { panic(err) @@ -62,103 +83,81 @@ func generateYAMLEntry(model ProcessedModel, familyAnchor string, quantization s filesYAML, _ := yaml.Marshal(modelConfig.Files) - files := formatTextContent(string(filesYAML)) + // Files section: list items need 4 spaces (not 6), since files: is at 2 spaces + files := formatTextContentWithIndent(string(filesYAML), 4, 4) + + // Build metadata sections + var metadataSections []string + + // Add license if present + if model.License != "" { + metadataSections = append(metadataSections, fmt.Sprintf(` license: "%s"`, model.License)) + } + + // Add tags if present + if len(model.Tags) > 0 { + tagsYAML, _ := yaml.Marshal(model.Tags) + tagsFormatted := formatTextContentWithIndent(string(tagsYAML), 4, 4) + tagsFormatted = strings.TrimRight(tagsFormatted, "\n") + metadataSections = append(metadataSections, fmt.Sprintf(" tags:\n%s", tagsFormatted)) + } + + // Add icon if present + if model.Icon != "" { + metadataSections = append(metadataSections, fmt.Sprintf(` icon: %s`, model.Icon)) + } + + // Build the metadata block + metadataBlock := "" + if len(metadataSections) > 0 { + metadataBlock = strings.Join(metadataSections, "\n") + "\n" + } yamlTemplate := "" - yamlTemplate = `- !!merge <<: *%s - name: "%s" + yamlTemplate = `- name: "%s" urls: - https://huggingface.co/%s description: | -%s +%s%s overrides: %s files: %s` + // Trim trailing newlines from formatted sections to prevent extra blank lines + formattedDescription = strings.TrimRight(formattedDescription, "\n") + configFile = strings.TrimRight(configFile, "\n") + files = strings.TrimRight(files, "\n") + // Add newline before metadata block if present + if metadataBlock != "" { + metadataBlock = "\n" + strings.TrimRight(metadataBlock, "\n") + } return fmt.Sprintf(yamlTemplate, - familyAnchor, modelName, model.ModelID, formattedDescription, + metadataBlock, configFile, files, ) } -// extractModelFamilies extracts all YAML anchors from the gallery index.yaml file -func extractModelFamilies() ([]ModelFamily, error) { - // Read the index.yaml file - indexPath := getGalleryIndexPath() - content, err := os.ReadFile(indexPath) - if err != nil { - return nil, fmt.Errorf("failed to read %s: %w", indexPath, err) - } - - lines := strings.Split(string(content), "\n") - var families []ModelFamily - - for _, line := range lines { - line = strings.TrimSpace(line) - // Look for YAML anchors (lines starting with "- &") - if strings.HasPrefix(line, "- &") { - // Extract the anchor name (everything after "- &") - anchor := strings.TrimPrefix(line, "- &") - // Remove any trailing colon or other characters - anchor = strings.Split(anchor, ":")[0] - anchor = strings.Split(anchor, " ")[0] - - if anchor != "" { - families = append(families, ModelFamily{ - Anchor: anchor, - Name: anchor, // Use anchor as name for now - }) - } - } - } - - return families, nil -} - // generateYAMLForModels generates YAML entries for selected models and appends to index.yaml func generateYAMLForModels(ctx context.Context, models []ProcessedModel, quantization string) error { - // Extract available model families - families, err := extractModelFamilies() - if err != nil { - return fmt.Errorf("failed to extract model families: %w", err) - } - - fmt.Printf("Found %d model families: %v\n", len(families), - func() []string { - var names []string - for _, f := range families { - names = append(names, f.Anchor) - } - return names - }()) // Generate YAML entries for each model var yamlEntries []string for _, model := range models { - fmt.Printf("Selecting family for model: %s\n", model.ModelID) - - // Select appropriate family for this model - familyAnchor, err := selectModelFamily(ctx, model, families) - if err != nil { - fmt.Printf("Error selecting family for %s: %v, using default\n", model.ModelID, err) - familyAnchor = "llama3" // Default fallback - } - - fmt.Printf("Selected family '%s' for model %s\n", familyAnchor, model.ModelID) + fmt.Printf("Generating YAML entry for model: %s\n", model.ModelID) // Generate YAML entry - yamlEntry := generateYAMLEntry(model, familyAnchor, quantization) + yamlEntry := generateYAMLEntry(model, quantization) yamlEntries = append(yamlEntries, yamlEntry) } - // Append to index.yaml + // Prepend to index.yaml (write at the top) if len(yamlEntries) > 0 { indexPath := getGalleryIndexPath() - fmt.Printf("Appending YAML entries to %s...\n", indexPath) + fmt.Printf("Prepending YAML entries to %s...\n", indexPath) // Read current content content, err := os.ReadFile(indexPath) @@ -166,11 +165,26 @@ func generateYAMLForModels(ctx context.Context, models []ProcessedModel, quantiz return fmt.Errorf("failed to read %s: %w", indexPath, err) } - // Append new entries - // Remove trailing whitespace from existing content and join entries without extra newlines - existingContent := strings.TrimRight(string(content), " \t\n\r") + existingContent := string(content) yamlBlock := strings.Join(yamlEntries, "\n") - newContent := existingContent + "\n" + yamlBlock + "\n" + + // Check if file starts with "---" + var newContent string + if strings.HasPrefix(existingContent, "---\n") { + // File starts with "---", prepend new entries after it + restOfContent := strings.TrimPrefix(existingContent, "---\n") + // Ensure proper spacing: "---\n" + new entries + "\n" + rest of content + newContent = "---\n" + yamlBlock + "\n" + restOfContent + } else if strings.HasPrefix(existingContent, "---") { + // File starts with "---" but no newline after + restOfContent := strings.TrimPrefix(existingContent, "---") + newContent = "---\n" + yamlBlock + "\n" + strings.TrimPrefix(restOfContent, "\n") + } else { + // No "---" at start, prepend new entries at the very beginning + // Trim leading whitespace from existing content + existingContent = strings.TrimLeft(existingContent, " \t\n\r") + newContent = yamlBlock + "\n" + existingContent + } // Write back to file err = os.WriteFile(indexPath, []byte(newContent), 0644) @@ -178,7 +192,7 @@ func generateYAMLForModels(ctx context.Context, models []ProcessedModel, quantiz return fmt.Errorf("failed to write %s: %w", indexPath, err) } - fmt.Printf("Successfully added %d models to %s\n", len(yamlEntries), indexPath) + fmt.Printf("Successfully prepended %d models to %s\n", len(yamlEntries), indexPath) } return nil diff --git a/.github/gallery-agent/main.go b/.github/gallery-agent/main.go index 7d9a4a462..ebef996ec 100644 --- a/.github/gallery-agent/main.go +++ b/.github/gallery-agent/main.go @@ -34,6 +34,9 @@ type ProcessedModel struct { ReadmeContentPreview string `json:"readme_content_preview,omitempty"` QuantizationPreferences []string `json:"quantization_preferences"` ProcessingError string `json:"processing_error,omitempty"` + Tags []string `json:"tags,omitempty"` + License string `json:"license,omitempty"` + Icon string `json:"icon,omitempty"` } // SearchResult represents the complete result of searching and processing models @@ -315,6 +318,25 @@ func searchAndProcessModels(searchTerm string, limit int, quantization string) ( continue } fmt.Println("Real readme got", readmeContent) + + // Extract metadata (tags, license) from README using LLM + fmt.Println("Extracting metadata for", model.ModelID, "waiting...") + tags, license, err := extractModelMetadata(context.Background(), processedModel) + if err == nil { + processedModel.Tags = tags + processedModel.License = license + outputBuilder.WriteString(fmt.Sprintf(" Tags: %v\n", tags)) + outputBuilder.WriteString(fmt.Sprintf(" License: %s\n", license)) + } else { + fmt.Printf(" Warning: Failed to extract metadata: %v\n", err) + } + + // Extract icon from README or use HuggingFace avatar + icon := extractModelIcon(processedModel) + if icon != "" { + processedModel.Icon = icon + outputBuilder.WriteString(fmt.Sprintf(" Icon: %s\n", icon)) + } // Get README content // readmeContent, err := client.GetReadmeContent(model.ModelID, details.ReadmeFile.Path) // if err == nil { diff --git a/.github/gallery-agent/testing.go b/.github/gallery-agent/testing.go index c6155f46c..c7960a9f2 100644 --- a/.github/gallery-agent/testing.go +++ b/.github/gallery-agent/testing.go @@ -138,6 +138,25 @@ func (g *SyntheticDataGenerator) GenerateProcessedModel() ProcessedModel { readmeContent := g.generateReadmeContent(modelName, author) + // Generate sample metadata + licenses := []string{"apache-2.0", "mit", "llama2", "gpl-3.0", "bsd", ""} + license := licenses[g.rand.Intn(len(licenses))] + + sampleTags := []string{"llm", "gguf", "gpu", "cpu", "text-to-text", "chat", "instruction-tuned"} + numTags := g.rand.Intn(4) + 3 // 3-6 tags + tags := make([]string, numTags) + for i := 0; i < numTags; i++ { + tags[i] = sampleTags[g.rand.Intn(len(sampleTags))] + } + // Remove duplicates + tags = g.removeDuplicates(tags) + + // Optionally include icon (50% chance) + icon := "" + if g.rand.Intn(2) == 0 { + icon = fmt.Sprintf("https://cdn-avatars.huggingface.co/v1/production/uploads/%s.png", g.randomString(24)) + } + return ProcessedModel{ ModelID: modelID, Author: author, @@ -150,6 +169,9 @@ func (g *SyntheticDataGenerator) GenerateProcessedModel() ProcessedModel { ReadmeContentPreview: truncateString(readmeContent, 200), QuantizationPreferences: []string{"Q4_K_M", "Q4_K_S", "Q3_K_M", "Q2_K"}, ProcessingError: "", + Tags: tags, + License: license, + Icon: icon, } } @@ -179,6 +201,18 @@ func (g *SyntheticDataGenerator) randomDate() string { return pastDate.Format("2006-01-02T15:04:05.000Z") } +func (g *SyntheticDataGenerator) removeDuplicates(slice []string) []string { + keys := make(map[string]bool) + result := []string{} + for _, item := range slice { + if !keys[item] { + keys[item] = true + result = append(result, item) + } + } + return result +} + func (g *SyntheticDataGenerator) generateReadmeContent(modelName, author string) string { templates := []string{ fmt.Sprintf("# %s Model\n\nThis is a %s model developed by %s. It's designed for various natural language processing tasks including text generation, question answering, and conversation.\n\n## Features\n\n- High-quality text generation\n- Efficient inference\n- Multiple quantization options\n- Easy to use with LocalAI\n\n## Usage\n\nUse this model with LocalAI for various AI tasks.", strings.Title(modelName), modelName, author),