FloorMaterialAnalyzer/internal/app/app.go

353 lines
11 KiB
Go
Raw Normal View History

2026-07-27 03:03:51 +00:00
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")
}