package service import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "net/http" "os" "sort" "strings" "materialanalyzer/internal/model" ) type SemanticProvider interface { Name() string Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error) } type FusionSemanticProvider struct { Providers []SemanticProvider } func (p FusionSemanticProvider) Name() string { names := make([]string, 0, len(p.Providers)) for _, provider := range p.Providers { names = append(names, provider.Name()) } return "fusion(" + strings.Join(names, "+") + ")" } func (p FusionSemanticProvider) Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error) { var results []model.SemanticFeatures var errors []string for _, provider := range p.Providers { semantic, err := provider.Analyze(ctx, imagePath, product, visual, texture) if err != nil { errors = append(errors, provider.Name()+": "+err.Error()) continue } results = append(results, semantic) } if len(results) == 0 { return model.SemanticFeatures{}, fmt.Errorf("all semantic providers failed: %s", strings.Join(errors, "; ")) } if len(results) == 1 { results[0].Provider = "fusion" results[0].Model = "single:" + results[0].Model return results[0], nil } return fuseSemantic(results), nil } type RuleBasedSemanticProvider struct{} func (RuleBasedSemanticProvider) Name() string { return "local" } func (RuleBasedSemanticProvider) Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error) { select { case <-ctx.Done(): return model.SemanticFeatures{}, ctx.Err() default: } return BuildSemantic(product, visual, texture), nil } type HTTPSemanticProvider struct { ProviderName string BaseURL string Model string Client *http.Client } func (p HTTPSemanticProvider) Name() string { if p.ProviderName != "" { return p.ProviderName } return "http" } func (p HTTPSemanticProvider) Analyze(ctx context.Context, imagePath string, product model.Product, visual model.VisualFeatures, texture model.TextureFeatures) (model.SemanticFeatures, error) { if strings.TrimSpace(p.BaseURL) == "" { return model.SemanticFeatures{}, fmt.Errorf("semantic HTTP provider URL is empty") } client := p.Client if client == nil { client = http.DefaultClient } imgData, err := os.ReadFile(imagePath) if err != nil { return model.SemanticFeatures{}, fmt.Errorf("read semantic image: %w", err) } body := map[string]interface{}{ "model": p.Model, "image_base64": base64.StdEncoding.EncodeToString(imgData), "image_path": imagePath, "product": product, "schema": map[string]interface{}{ "required": []string{"description", "material_type", "surface_finish", "grain_type", "color_family", "visual_style", "variation", "gloss_level", "tags"}, }, } payload, err := json.Marshal(body) if err != nil { return model.SemanticFeatures{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, semanticEndpoint(p.BaseURL), bytes.NewReader(payload)) if err != nil { return model.SemanticFeatures{}, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return model.SemanticFeatures{}, fmt.Errorf("semantic HTTP request: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return model.SemanticFeatures{}, fmt.Errorf("semantic HTTP status %d", resp.StatusCode) } var semantic model.SemanticFeatures if err := json.NewDecoder(resp.Body).Decode(&semantic); err != nil { return model.SemanticFeatures{}, fmt.Errorf("decode semantic response: %w", err) } if semantic.Provider == "" { semantic.Provider = p.Name() } if semantic.Model == "" { semantic.Model = p.Model } if semantic.Confidence == 0 { semantic.Confidence = 0.80 } if err := validateSemanticRequired(semantic); err != nil { return model.SemanticFeatures{}, err } return semantic, nil } func fuseSemantic(results []model.SemanticFeatures) model.SemanticFeatures { best := results[0] for _, candidate := range results[1:] { if candidate.Confidence > best.Confidence { best = candidate } } out := model.SemanticFeatures{ Provider: "fusion", Model: fusionModels(results), Description: best.Description, MaterialType: pickField(results, "material_type", func(s model.SemanticFeatures) string { return s.MaterialType }), SurfaceFinish: pickField(results, "surface_finish", func(s model.SemanticFeatures) string { return s.SurfaceFinish }), GrainType: pickField(results, "grain_type", func(s model.SemanticFeatures) string { return s.GrainType }), ColorFamily: pickField(results, "color_family", func(s model.SemanticFeatures) string { return s.ColorFamily }), VisualStyle: pickField(results, "visual_style", func(s model.SemanticFeatures) string { return s.VisualStyle }), Variation: pickField(results, "variation", func(s model.SemanticFeatures) string { return s.Variation }), GlossLevel: pickField(results, "gloss_level", func(s model.SemanticFeatures) string { return s.GlossLevel }), Confidence: averageConfidence(results), FieldConfidence: map[string]float64{}, } out.Grain = out.GrainType out.Tags = fusedTags(results) for _, field := range []string{"material_type", "surface_finish", "grain_type", "color_family", "visual_style", "variation", "gloss_level"} { out.FieldConfidence[field] = fieldConfidence(results, field) } return out } func pickField(results []model.SemanticFeatures, field string, getter func(model.SemanticFeatures) string) string { type score struct { value string score float64 votes int } scores := map[string]*score{} for _, result := range results { value := strings.TrimSpace(getter(result)) if value == "" { continue } key := strings.ToLower(value) weight := result.Confidence if result.FieldConfidence != nil && result.FieldConfidence[field] > 0 { weight *= result.FieldConfidence[field] } if scores[key] == nil { scores[key] = &score{value: value} } scores[key].score += weight scores[key].votes++ } list := make([]score, 0, len(scores)) for _, item := range scores { list = append(list, *item) } sort.Slice(list, func(i, j int) bool { if list[i].votes != list[j].votes { return list[i].votes > list[j].votes } return list[i].score > list[j].score }) if len(list) == 0 { return "" } return list[0].value } func fieldConfidence(results []model.SemanticFeatures, field string) float64 { if len(results) == 0 { return 0 } var sum float64 for _, result := range results { conf := result.Confidence if result.FieldConfidence != nil && result.FieldConfidence[field] > 0 { conf = result.FieldConfidence[field] } sum += conf } return sum / float64(len(results)) } func averageConfidence(results []model.SemanticFeatures) float64 { var sum float64 for _, result := range results { sum += result.Confidence } return sum / float64(len(results)) } func fusionModels(results []model.SemanticFeatures) string { models := make([]string, 0, len(results)) for _, result := range results { models = append(models, result.Provider+":"+result.Model) } return strings.Join(models, "+") } func fusedTags(results []model.SemanticFeatures) []string { seen := map[string]bool{} var tags []string for _, result := range results { for _, tag := range result.Tags { key := strings.ToLower(strings.TrimSpace(tag)) if key == "" || seen[key] { continue } seen[key] = true tags = append(tags, tag) } } return tags } func semanticEndpoint(base string) string { base = strings.TrimRight(base, "/") if strings.HasSuffix(base, "/semantic") { return base } return base + "/semantic" } func validateSemanticRequired(s model.SemanticFeatures) error { missing := make([]string, 0) required := map[string]string{ "description": s.Description, "material_type": s.MaterialType, "surface_finish": s.SurfaceFinish, "grain_type": s.GrainType, "color_family": s.ColorFamily, "visual_style": s.VisualStyle, "variation": s.Variation, "gloss_level": s.GlossLevel, } for name, value := range required { if strings.TrimSpace(value) == "" { missing = append(missing, name) } } if len(s.Tags) == 0 { missing = append(missing, "tags") } if len(missing) > 0 { return fmt.Errorf("semantic response missing fields: %s", strings.Join(missing, ", ")) } return nil }