package marketing import ( "encoding/json" "strings" ) // QualityCheckKey identifies a completeness / SEO signal. type QualityCheckKey string const ( CheckTitle QualityCheckKey = "title" CheckDescription QualityCheckKey = "description" CheckMetaTitle QualityCheckKey = "meta_title" CheckMetaDescription QualityCheckKey = "meta_description" CheckCategory QualityCheckKey = "category" CheckAttributes QualityCheckKey = "attributes" CheckImage QualityCheckKey = "image" ) // QualityCheck is one weighted gate in the score. type QualityCheck struct { Passed bool `json:"passed"` Weight int `json:"weight"` Label string `json:"label"` } // QualityResult is a 0–100 completeness / SEO score. type QualityResult struct { Score int `json:"score"` MaxScore int `json:"max_score"` Grade string `json:"grade"` Checks map[QualityCheckKey]QualityCheck `json:"checks"` } // ProductInput is the field snapshot used for scoring (no DB column required). type ProductInput struct { Name string ProcessedName string Description string ProcessedDescription string MetaTitle string MetaDescription string Category string Attributes any ProcessedAttributes any MappedData map[string]any } var qualityWeights = map[QualityCheckKey]int{ CheckTitle: 20, CheckDescription: 20, CheckMetaTitle: 15, CheckMetaDescription: 15, CheckCategory: 10, CheckAttributes: 10, CheckImage: 10, } var qualityLabels = map[QualityCheckKey]string{ CheckTitle: "Title", CheckDescription: "Description", CheckMetaTitle: "Meta title", CheckMetaDescription: "Meta description", CheckCategory: "Category", CheckAttributes: "Attributes", CheckImage: "Image", } var qualityOrder = []QualityCheckKey{ CheckTitle, CheckDescription, CheckMetaTitle, CheckMetaDescription, CheckCategory, CheckAttributes, CheckImage, } func hasText(value string, minLen int) bool { return len(strings.TrimSpace(value)) >= minLen } func countAttributes(value any) int { if value == nil { return 0 } switch v := value.(type) { case []any: return len(v) case map[string]any: return len(v) case string: s := strings.TrimSpace(v) if s == "" || s == "{}" || s == "[]" || s == "null" { return 0 } var arr []any if err := json.Unmarshal([]byte(s), &arr); err == nil { return len(arr) } var obj map[string]any if err := json.Unmarshal([]byte(s), &obj); err == nil { return len(obj) } return 0 case []byte: return countAttributes(string(v)) default: b, err := json.Marshal(v) if err != nil { return 0 } return countAttributes(string(b)) } } func hasImage(mapped map[string]any) bool { if mapped == nil { return false } keys := []string{"image", "image_link", "image_url", "images", "main_image", "primary_image", "picture", "photo"} for _, key := range keys { raw, ok := mapped[key] if !ok || raw == nil { continue } switch v := raw.(type) { case string: if strings.TrimSpace(v) != "" { return true } case []any: if len(v) > 0 { return true } } } return false } func gradeFromScore(score int) string { switch { case score >= 90: return "A" case score >= 75: return "B" case score >= 60: return "C" case score >= 40: return "D" default: return "F" } } // ComputeProductQualityScore scores completeness + SEO fields (0–100). func ComputeProductQualityScore(in ProductInput) QualityResult { mappedName := "" mappedDesc := "" if in.MappedData != nil { if s, ok := in.MappedData["name"].(string); ok { mappedName = s } else if s, ok := in.MappedData["title"].(string); ok { mappedName = s } if s, ok := in.MappedData["description"].(string); ok { mappedDesc = s } } passed := map[QualityCheckKey]bool{ CheckTitle: hasText(in.ProcessedName, 3) || hasText(in.Name, 3) || hasText(mappedName, 3), CheckDescription: hasText(in.ProcessedDescription, 20) || hasText(in.Description, 20) || hasText(mappedDesc, 20), CheckMetaTitle: hasText(in.MetaTitle, 10), CheckMetaDescription: hasText(in.MetaDescription, 40), CheckCategory: hasText(in.Category, 1), CheckAttributes: countAttributes(in.ProcessedAttributes) > 0 || countAttributes(in.Attributes) > 0, CheckImage: hasImage(in.MappedData), } score := 0 checks := make(map[QualityCheckKey]QualityCheck, len(qualityOrder)) for _, key := range qualityOrder { w := qualityWeights[key] ok := passed[key] if ok { score += w } checks[key] = QualityCheck{Passed: ok, Weight: w, Label: qualityLabels[key]} } return QualityResult{ Score: score, MaxScore: 100, Grade: gradeFromScore(score), Checks: checks, } } // ScoreFromProductMap builds ProductInput from a catalog row map and scores it. func ScoreFromProductMap(m map[string]any) QualityResult { in := ProductInput{ Name: asString(m["name"]), ProcessedName: asString(m["processed_name"]), Description: asString(m["description"]), ProcessedDescription: asString(m["processed_description"]), MetaTitle: asString(m["meta_title"]), MetaDescription: asString(m["meta_description"]), Category: asString(m["category"]), Attributes: m["attributes"], ProcessedAttributes: m["processed_attributes"], } if md, ok := m["mapped_data"].(map[string]any); ok { in.MappedData = md } else if raw, ok := m["mapped_data"].([]byte); ok && len(raw) > 0 { var obj map[string]any if json.Unmarshal(raw, &obj) == nil { in.MappedData = obj } } else if s := asString(m["mapped_data"]); s != "" { var obj map[string]any if json.Unmarshal([]byte(s), &obj) == nil { in.MappedData = obj } } return ComputeProductQualityScore(in) } func asString(v any) string { switch t := v.(type) { case string: return t case []byte: return string(t) case nil: return "" default: return "" } }