package app import ( "context" "flag" "fmt" "net/http" "os" "os/signal" "path/filepath" "strings" "sync" "time" "materialanalyzer/internal/embedding" "materialanalyzer/internal/intelligence" "materialanalyzer/internal/model" "materialanalyzer/internal/output" "materialanalyzer/internal/repository" "materialanalyzer/internal/service" ) type workerResult struct { result service.ProcessResult err error attempts int duration time.Duration } func Run(args []string) int { if len(args) > 0 { switch args[0] { case "intelligence", "build-intelligence": return intelligence.Run(args[1:]) case "analyze": args = args[1:] } } fs := flag.NewFlagSet("material-analyzer", flag.ContinueOnError) dataDir := fs.String("data-dir", defaultDataDir(), "product JSON directory") outputDir := fs.String("output-dir", "MaterialAssets", "material asset output directory") cacheDir := fs.String("cache-dir", filepath.Join("cache", "images"), "downloaded image cache directory") workers := fs.Int("workers", 4, "number of concurrent image workers") limit := fs.Int("limit", 0, "maximum products to process; 0 means all") sku := fs.String("sku", "", "comma-separated SKU filter") force := fs.Bool("force", false, "reprocess products even if material.json already exists") timeout := fs.Duration("timeout", 40*time.Second, "per-image/model request timeout") strict := fs.Bool("strict", false, "return non-zero exit code when any product fails") verbose := fs.Bool("verbose", false, "print every processed SKU") retry := fs.Int("retry", 1, "retry count per failed SKU") allowFallback := fs.Bool("allow-fallback", true, "fallback to local semantic/embedding providers when configured providers fail") embeddingProviderName := fs.String("embedding-provider", envOr("EMBEDDING_PROVIDER", "local"), "embedding provider: local or http") embeddingURL := fs.String("embedding-url", envOr("EMBEDDING_SERVER_URL", ""), "embedding HTTP server base URL") semanticProviderName := fs.String("semantic-provider", envOr("SEMANTIC_PROVIDER", "local"), "semantic provider: local or http") semanticURL := fs.String("semantic-url", envOr("SEMANTIC_SERVER_URL", ""), "semantic HTTP server base URL") semanticModel := fs.String("semantic-model", envOr("SEMANTIC_MODEL", "gemini-3-pro-image"), "semantic vision model metadata") if err := fs.Parse(args); err != nil { return 2 } if *workers <= 0 { *workers = 1 } if *retry < 0 { *retry = 0 } products, err := repository.LoadProducts(*dataDir) if err != nil { fmt.Fprintf(os.Stderr, "load products: %v\n", err) return 1 } products = repository.FilterProducts(products, *sku, *limit) if len(products) == 0 { fmt.Fprintln(os.Stderr, "no products matched") return 1 } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() client := &http.Client{Timeout: *timeout} embeddingProvider := newEmbeddingProvider(*embeddingProviderName, *embeddingURL, client) semanticProvider := newSemanticProvider(*semanticProviderName, *semanticURL, *semanticModel, client) processor := service.NewProcessor(service.ProcessorConfig{ OutputDir: *outputDir, CacheDir: *cacheDir, Force: *force, AllowFallback: *allowFallback, EmbeddingProvider: embeddingProvider, SemanticProvider: semanticProvider, }, client) started := time.Now() fmt.Printf("Material Analyzer %s\n", service.AnalyzerVersion) fmt.Printf("data=%s output=%s cache=%s products=%d workers=%d retry=%d\n", *dataDir, *outputDir, *cacheDir, len(products), *workers, *retry) fmt.Printf("providers embedding=%s semantic=%s fallback=%v\n", embeddingProvider.Name(), semanticProvider.Name(), *allowFallback) jobs := make(chan model.Product) results := make(chan workerResult) var wg sync.WaitGroup for i := 0; i < *workers; i++ { wg.Add(1) go func() { defer wg.Done() for product := range jobs { result, err, attempts, duration := processWithRetry(ctx, processor, product, *retry) results <- workerResult{result: result, err: err, attempts: attempts, duration: duration} } }() } go func() { defer close(jobs) for _, product := range products { select { case <-ctx.Done(): return case jobs <- product: } } }() go func() { wg.Wait() close(results) }() done, processed, skipped, failed := 0, 0, 0, 0 var processedDuration time.Duration resolutionDist := map[string]int{} failures := make([]model.FailureItem, 0) for r := range results { done++ if r.result.Width > 0 && r.result.Height > 0 { resolutionDist[resolutionBucket(r.result.Width, r.result.Height)]++ } if r.err != nil { failed++ service.RemoveIncompleteAsset(r.result.AssetDir) failures = append(failures, model.FailureItem{ SKU: r.result.SKU, Error: r.err.Error(), Attempts: r.attempts, DurationMS: r.duration.Milliseconds(), }) fmt.Fprintf(os.Stderr, "[FAIL] %s attempts=%d: %v\n", r.result.SKU, r.attempts, r.err) } else if r.result.Skipped { skipped++ if *verbose { fmt.Printf("[SKIP] %s\n", r.result.SKU) } } else { processed++ processedDuration += r.duration if *verbose { fmt.Printf("[OK] %s -> %s (%s)\n", r.result.SKU, r.result.AssetDir, r.duration.Round(time.Millisecond)) } } if len(r.result.Warnings) > 0 && *verbose { fmt.Printf(" warnings: %v\n", r.result.Warnings) } if !*verbose && (done%25 == 0 || done == len(products)) { fmt.Printf("progress %d/%d %.1f%% (processed=%d skipped=%d failed=%d)\n", done, len(products), float64(done)*100/float64(len(products)), processed, skipped, failed) } } avgMS := 0.0 if processed > 0 { avgMS = float64(processedDuration.Milliseconds()) / float64(processed) } benchmark := model.BatchBenchmark{ AnalyzerVersion: service.AnalyzerVersion, FeatureSchema: service.FeatureSchema, Generator: service.Generator, StartedAt: started.UTC().Format(time.RFC3339), CompletedAt: time.Now().UTC().Format(time.RFC3339), DataDir: *dataDir, OutputDir: *outputDir, EmbeddingProvider: embeddingProvider.Name(), SemanticProvider: semanticProvider.Name(), TotalSKU: len(products), Processed: processed, Skipped: skipped, Failed: failed, AverageTimeMS: round2(avgMS), Workers: *workers, ImageResolutionDistribution: resolutionDist, } if err := output.WriteBenchmark(*outputDir, benchmark); err != nil { fmt.Fprintf(os.Stderr, "write benchmark: %v\n", err) } if len(failures) > 0 { if err := output.WriteFailures(*outputDir, model.FailureReport{ GeneratedAt: time.Now().UTC().Format(time.RFC3339), Failures: failures, }); err != nil { fmt.Fprintf(os.Stderr, "write failures: %v\n", err) } } fmt.Printf("Done: total=%d processed=%d skipped=%d failed=%d avg_ms=%.2f\n", len(products), processed, skipped, failed, avgMS) if failed > 0 && *strict { return 1 } if ctx.Err() != nil { return 130 } return 0 } func processWithRetry(ctx context.Context, processor *service.Processor, product model.Product, retry int) (service.ProcessResult, error, int, time.Duration) { start := time.Now() var result service.ProcessResult var err error attempts := 0 for attempts < retry+1 { attempts++ result, err = processor.Process(ctx, product) if err == nil || result.Skipped || ctx.Err() != nil { break } if attempts <= retry { time.Sleep(time.Duration(attempts) * 500 * time.Millisecond) } } return result, err, attempts, time.Since(start) } func newEmbeddingProvider(name, url string, client *http.Client) embedding.Provider { switch strings.ToLower(strings.TrimSpace(name)) { case "http", "server", "model-server", "real": return embedding.HTTPProvider{BaseURL: url, Client: client} default: return embedding.LocalProvider{} } } func newSemanticProvider(name, url, modelName string, client *http.Client) service.SemanticProvider { names := strings.Split(name, ",") providers := make([]service.SemanticProvider, 0, len(names)) for _, item := range names { item = strings.ToLower(strings.TrimSpace(item)) if item == "" { continue } switch item { case "local", "rule", "rules": providers = append(providers, service.RuleBasedSemanticProvider{}) case "http", "server", "vision", "llm": providers = append(providers, service.HTTPSemanticProvider{ProviderName: "http", BaseURL: url, Model: modelName, Client: client}) case "internvl3", "florence2", "qwen2_5vl", "qwen2.5vl", "gemini", "gpt4o": providerName := normalizeProviderName(item) providers = append(providers, service.HTTPSemanticProvider{ ProviderName: providerName, BaseURL: providerURL(providerName, url), Model: providerModel(providerName, modelName), Client: client, }) default: providers = append(providers, service.RuleBasedSemanticProvider{}) } } if len(providers) == 0 { return service.RuleBasedSemanticProvider{} } if len(providers) == 1 { return providers[0] } return service.FusionSemanticProvider{Providers: providers} } func normalizeProviderName(name string) string { if name == "qwen2.5vl" { return "qwen2_5vl" } return name } func providerURL(providerName, fallback string) string { envName := strings.ToUpper(strings.ReplaceAll(providerName, ".", "_")) + "_VISION_URL" if v := os.Getenv(envName); v != "" { return v } if strings.TrimSpace(fallback) != "" { return fallback } if providerName == "internvl3" { return "http://127.0.0.1:5300" } return fallback } func providerModel(providerName, fallback string) string { if fallback != "" && fallback != "gemini-3-pro-image" { return fallback } switch providerName { case "internvl3": return "OpenGVLab/InternVL3-8B" case "florence2": return "Florence-2" case "qwen2_5vl": return "Qwen2.5-VL" case "gpt4o": return "gpt-4o" case "gemini": return "gemini-3-pro-image" default: return fallback } } func resolutionBucket(width, height int) string { maxDim := width if height > maxDim { maxDim = height } switch { case maxDim <= 512: return "<=512" case maxDim <= 1024: return "513-1024" case maxDim <= 2048: return "1025-2048" default: return ">2048" } } func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } func envOr(name, fallback string) string { if v := os.Getenv(name); v != "" { return v } return fallback } func defaultDataDir() string { if v := os.Getenv("PRODUCT_DATA_DIR"); v != "" { return v } cwd, err := os.Getwd() if err == nil { sibling := filepath.Clean(filepath.Join(cwd, "..", "FloorVisualizer", "data", "products")) if stat, err := os.Stat(sibling); err == nil && stat.IsDir() { return sibling } } return filepath.Join("data", "products") }