package intelligence import ( "encoding/binary" "encoding/json" "fmt" "io" "math" "os" "path/filepath" "sort" "strings" "materialanalyzer/internal/model" ) type loadedAsset struct { Asset model.MaterialAsset AssetDir string Histogram model.ColorHistogram Embeddings []EmbeddingRecord } func loadAssets(assetsDir string) ([]loadedAsset, error) { entries, err := os.ReadDir(assetsDir) if err != nil { return nil, fmt.Errorf("read assets dir: %w", err) } out := make([]loadedAsset, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() { continue } assetDir := filepath.Join(assetsDir, entry.Name()) asset, err := readMaterialAsset(filepath.Join(assetDir, "material.json")) if err != nil { continue } hist, _ := readHistogram(filepath.Join(assetDir, "histogram.json")) embeddings, _ := readEmbeddings(filepath.Join(assetDir, "embedding.bin"), asset.Embeddings) out = append(out, loadedAsset{Asset: asset, AssetDir: assetDir, Histogram: hist, Embeddings: embeddings}) } sort.Slice(out, func(i, j int) bool { return out[i].Asset.SKU < out[j].Asset.SKU }) return out, nil } func readMaterialAsset(path string) (model.MaterialAsset, error) { file, err := os.Open(path) if err != nil { return model.MaterialAsset{}, err } defer file.Close() var asset model.MaterialAsset if err := json.NewDecoder(file).Decode(&asset); err != nil { return model.MaterialAsset{}, err } if strings.TrimSpace(asset.SKU) == "" { return model.MaterialAsset{}, fmt.Errorf("asset %s has no SKU", path) } migrateAssetDefaults(&asset) return asset, nil } func migrateAssetDefaults(asset *model.MaterialAsset) { if asset.Semantic.MaterialType == "" { asset.Semantic.MaterialType = firstNonEmpty(familyFromSpecific(asset.MaterialSpecific), asset.Category, asset.Material) } if asset.Semantic.GrainType == "" { asset.Semantic.GrainType = asset.Semantic.Grain } if asset.Semantic.GlossLevel == "" { asset.Semantic.GlossLevel = glossFromFinish(asset.Semantic.SurfaceFinish) } if asset.Semantic.Confidence == 0 { asset.Semantic.Confidence = 0.65 } if asset.AnalyzerVersion == "" { asset.AnalyzerVersion = "1.x" } if asset.FeatureSchema == "" { asset.FeatureSchema = "legacy" } } func glossFromFinish(finish string) string { lower := strings.ToLower(finish) switch { case strings.Contains(lower, "gloss"): return "High" case strings.Contains(lower, "satin"): return "Medium" case strings.Contains(lower, "matte"): return "Low" default: return "Unknown" } } func readHistogram(path string) (model.ColorHistogram, error) { file, err := os.Open(path) if err != nil { return model.ColorHistogram{}, err } defer file.Close() var hist model.ColorHistogram return hist, json.NewDecoder(file).Decode(&hist) } func readEmbeddings(path string, manifest model.EmbeddingManifest) ([]EmbeddingRecord, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() data, err := io.ReadAll(file) if err != nil { return nil, err } records := make([]EmbeddingRecord, 0, len(manifest.Vectors)) for _, vector := range manifest.Vectors { start := int(vector.OffsetBytes) byteLen := int(vector.ByteLength) if byteLen == 0 && vector.Dimensions > 0 { byteLen = vector.Dimensions * 4 } end := start + byteLen if start < 0 || end > len(data) || byteLen%4 != 0 { continue } values := make([]float32, byteLen/4) for i := range values { values[i] = math.Float32frombits(binary.LittleEndian.Uint32(data[start+i*4 : start+i*4+4])) } records = append(records, EmbeddingRecord{ Name: vector.Name, Model: vector.Model, Provider: vector.Provider, Dimensions: len(values), Values: values, }) } return records, nil }